From 8af86e9e895381f41b2ac645b0ae80d4a60a4475 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 6 Mar 2026 13:28:46 +0700 Subject: [PATCH] feat: implement Phase 1 payment gateway, real-time SignalR, kitchen-inventory deduction, and order payment flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wallet-service: IPaymentGateway abstraction + VN Pay implementation (HMAC-SHA512, sandbox), Payment aggregate root, PaymentsController with create/callback/query endpoints - order-service: PosHub SignalR hub with Redis backplane + MessagePack, strongly-typed clients, 3 group types (shop/kds/pos), integrated into Create/Pay/Complete/Cancel order handlers - fnb-engine + inventory-service: Kitchen→Inventory auto-deduction via domain events, HTTP with Polly retry + circuit breaker, idempotency check, graceful degradation on insufficient stock - order-service: Enhanced PayOrderCommand with 3 flows (cash/card/online), PaymentPending status, WalletServiceClient, CompleteOrderPaymentCommand for gateway callbacks - POS frontend: Cash/Card/QR payment components wired to real backend, BFF proxy updated - infra: Traefik routes for fnb-engine, inventory-service, and SignalR WebSocket hub - ROADMAP.md: Updated with Phase 1 progress tracking Co-Authored-By: Claude Opus 4.6 --- ROADMAP.md | 30 +- .../Pos/Shared/Payment/CardPayment.razor | 6 +- .../Pos/Shared/Payment/CashPayment.razor | 10 +- .../Pages/Pos/Shared/Payment/QrPayment.razor | 29 +- .../Services/PosDataService.cs | 44 ++- .../Controllers/OrderController.cs | 18 +- .../WebClientTpos.Server/Models/BffDtos.cs | 3 + infra/traefik/dynamic/routes.yml | 70 +++- .../Commands/CreateKitchenTicketCommand.cs | 4 +- .../CreateKitchenTicketCommandHandler.cs | 2 + .../KitchenTicketServedDomainEventHandler.cs | 141 ++++++++ .../KitchenTicketServedIntegrationEvent.cs | 19 + .../Controllers/KitchenController.cs | 8 +- .../KitchenAggregate/KitchenTicket.cs | 34 ++ .../Events/KitchenTicketServedDomainEvent.cs | 13 + .../DependencyInjection.cs | 27 ++ .../KitchenTicketEntityTypeConfiguration.cs | 11 + .../IInventoryServiceClient.cs | 38 ++ .../InventoryServiceClient.cs | 81 +++++ .../DeductInventory/DeductInventoryCommand.cs | 47 +++ .../DeductInventoryCommandHandler.cs | 146 ++++++++ .../Application/DTOs/InventoryDtos.cs | 21 ++ .../DeductInventoryCommandValidator.cs | 44 +++ .../Controllers/InventoryController.cs | 34 ++ .../InventoryContext.cs | 13 + .../Commands/CancelOrderCommandHandler.cs | 18 + .../Commands/CompleteOrderCommandHandler.cs | 18 + .../Commands/CompleteOrderPaymentCommand.cs | 27 ++ .../CompleteOrderPaymentCommandHandler.cs | 82 +++++ .../Commands/CreateOrderCommandHandler.cs | 42 +++ .../Application/Commands/PayOrderCommand.cs | 20 +- .../Commands/PayOrderCommandHandler.cs | 187 +++++++++- .../Application/DTOs/OrderDtos.cs | 4 + .../Queries/GetOrderByIdQueryHandler.cs | 14 +- .../Validations/PayOrderCommandValidator.cs | 39 ++- .../Controllers/OrdersController.cs | 78 ++++- .../Hubs/ClaimsUserIdProvider.cs | 28 ++ .../src/OrderService.API/Hubs/HubDtos.cs | 91 +++++ .../OrderService.API/Hubs/IPosHubClient.cs | 53 +++ .../Hubs/IPosNotificationService.cs | 53 +++ .../Hubs/PosConnectionManager.cs | 92 +++++ .../src/OrderService.API/Hubs/PosHub.cs | 203 +++++++++++ .../Hubs/PosNotificationService.cs | 155 +++++++++ .../OrderService.API/OrderService.API.csproj | 4 + .../src/OrderService.API/Program.cs | 88 ++++- .../src/OrderService.API/appsettings.json | 14 + .../AggregatesModel/OrderAggregate/Order.cs | 78 ++++- .../OrderAggregate/OrderStatus.cs | 6 + .../Events/OrderDomainEvents.cs | 6 + .../DependencyInjection.cs | 4 + .../OrderEntityTypeConfiguration.cs | 20 ++ .../OrderStatusEntityTypeConfiguration.cs | 3 +- .../ExternalServices/IWalletServiceClient.cs | 40 +++ .../ExternalServices/WalletServiceClient.cs | 78 +++++ .../Domain/OrderAggregateTests.cs | 4 +- .../Commands/Payments/CreatePaymentCommand.cs | 31 ++ .../Payments/CreatePaymentCommandHandler.cs | 95 +++++ .../Payments/ProcessPaymentCallbackCommand.cs | 24 ++ .../ProcessPaymentCallbackCommandHandler.cs | 134 +++++++ .../Queries/Payments/GetPaymentQuery.cs | 34 ++ .../Payments/GetPaymentQueryHandler.cs | 99 ++++++ .../CreatePaymentCommandValidator.cs | 64 ++++ .../Controllers/PaymentsController.cs | 161 +++++++++ .../appsettings.Development.json | 7 + .../PaymentAggregate/IPaymentGateway.cs | 55 +++ .../PaymentAggregate/IPaymentRepository.cs | 46 +++ .../PaymentAggregate/Payment.cs | 218 ++++++++++++ .../PaymentAggregate/PaymentRequest.cs | 39 +++ .../PaymentAggregate/PaymentStatus.cs | 42 +++ .../Events/PaymentCompletedDomainEvent.cs | 32 ++ .../Events/PaymentCreatedDomainEvent.cs | 32 ++ .../Events/PaymentFailedDomainEvent.cs | 29 ++ .../DependencyInjection.cs | 9 + .../PaymentEntityTypeConfiguration.cs | 99 ++++++ .../PaymentGateways/VnPayGateway.cs | 326 ++++++++++++++++++ .../PaymentGateways/VnPayOptions.cs | 44 +++ .../Repositories/PaymentRepository.cs | 64 ++++ .../WalletServiceContext.cs | 2 + 78 files changed, 4047 insertions(+), 81 deletions(-) create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/EventHandlers/KitchenTicketServedDomainEventHandler.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/Events/KitchenTicketServedIntegrationEvent.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Domain/Events/KitchenTicketServedDomainEvent.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/IInventoryServiceClient.cs create mode 100644 services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/InventoryServiceClient.cs create mode 100644 services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommand.cs create mode 100644 services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommandHandler.cs create mode 100644 services/inventory-service-net/src/InventoryService.API/Application/Validations/DeductInventoryCommandValidator.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Commands/CompleteOrderPaymentCommand.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Commands/CompleteOrderPaymentCommandHandler.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/ClaimsUserIdProvider.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/HubDtos.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/IPosHubClient.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/IPosNotificationService.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/PosConnectionManager.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/PosHub.cs create mode 100644 services/order-service-net/src/OrderService.API/Hubs/PosNotificationService.cs create mode 100644 services/order-service-net/src/OrderService.Infrastructure/ExternalServices/IWalletServiceClient.cs create mode 100644 services/order-service-net/src/OrderService.Infrastructure/ExternalServices/WalletServiceClient.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQuery.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQueryHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Validations/CreatePaymentCommandValidator.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Controllers/PaymentsController.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentGateway.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentRepository.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/Payment.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentRequest.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentStatus.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/PaymentCompletedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/PaymentCreatedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/PaymentFailedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayGateway.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayOptions.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PaymentRepository.cs diff --git a/ROADMAP.md b/ROADMAP.md index ec5e05fb..6c441470 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,12 +91,12 @@ | # | Gap | Status | Owner | Sprint | Notes | |:-:|-----|:------:|-------|:------:|-------| -| 1 | Payment Gateway — VN Pay | `TODO` | Backend #1 | Phase 1 / W1-2 | wallet-service integration | +| 1 | Payment Gateway — VN Pay | `DONE` | Backend #1 | Phase 1 / W1-2 | wallet-service: IPaymentGateway + VnPayGateway | | 2 | Payment Gateway — Momo | `TODO` | Backend #2 | Phase 1 / W1-2 | wallet-service integration | -| 3 | Real-time SignalR Hub | `TODO` | Backend #3 | Phase 1 / W1-2 | KDS + order push updates | -| 4 | Kitchen → Inventory Deduction | `TODO` | Backend #1 | Phase 1 / W2 | fnb-engine → inventory-service | +| 3 | Real-time SignalR Hub | `DONE` | Backend #3 | Phase 1 / W1-2 | PosHub + Redis backplane + MessagePack | +| 4 | Kitchen → Inventory Deduction | `DONE` | Backend #1 | Phase 1 / W2 | Domain events + HTTP + Polly + idempotency | | 5 | Multi-tenant Row-Level Security | `TODO` | Backend #2 | Phase 1 / W2-3 | All services, DB level | -| 6 | Payment UI Completion | `TODO` | Frontend Blazor | Phase 1 / W1-2 | Connect to real gateway | +| 6 | Payment UI Completion | `DONE` | Frontend Blazor | Phase 1 / W1-2 | Cash/Card/QR wired to real gateway | | 7 | Integration Test Suite | `TODO` | QA Engineer | Phase 1 / W3 | Order lifecycle e2e | | 8 | Staging Deployment | `TODO` | DevOps | Phase 1 / W3-4 | K8s + monitoring | @@ -133,18 +133,18 @@ | Task | Agent | Status | Depends On | |------|-------|:------:|:----------:| -| VN Pay payment gateway integration | Senior Backend #1 | `TODO` | wallet-service | +| VN Pay payment gateway integration | Senior Backend #1 | `DONE` | wallet-service | | Momo payment gateway integration | Senior Backend #2 | `TODO` | wallet-service | -| SignalR hub for real-time updates | Senior Backend #3 | `TODO` | — | -| KDS push notifications via SignalR | Senior Backend #3 | `TODO` | SignalR hub | -| Payment UI — connect to real gateway | Senior Frontend | `TODO` | Payment backends | -| Order status push to POS | Senior Backend #3 | `TODO` | SignalR hub | +| SignalR hub for real-time updates | Senior Backend #3 | `DONE` | — | +| KDS push notifications via SignalR | Senior Backend #3 | `DONE` | SignalR hub | +| Payment UI — connect to real gateway | Senior Frontend | `DONE` | Payment backends | +| Order status push to POS | Senior Backend #3 | `DONE` | SignalR hub | #### Week 2-3: Data Integrity & Security | Task | Agent | Status | Depends On | |------|-------|:------:|:----------:| -| Kitchen → Inventory auto-deduction | Senior Backend #1 | `TODO` | fnb-engine, inventory | +| Kitchen → Inventory auto-deduction | Senior Backend #1 | `DONE` | fnb-engine, inventory | | Row-level security (all services) | Senior Backend #2 | `TODO` | — | | Rate limiting audit | DevOps | `TODO` | — | | Input sanitization audit | QA | `TODO` | — | @@ -215,10 +215,14 @@ ## VI. Recently Completed -### 2026-03-06 +### 2026-03-06 (Phase 1 Sprint) | Task | Agent | Details | |------|-------|---------| +| VN Pay Payment Gateway | Backend #1 | IPaymentGateway abstraction + VnPayGateway (HMAC-SHA512, sandbox), Payment aggregate, PaymentsController (4 endpoints) | +| SignalR POS Hub | Backend #3 | PosHub (strongly-typed, 3 groups: shop/kds/pos), Redis backplane, MessagePack, 7 client methods, integrated into 4 order handlers | +| Kitchen → Inventory Deduction | Backend #1 | KitchenTicketServedDomainEvent → HTTP call to inventory-service, Polly retry + circuit breaker, idempotency, graceful degradation | +| Order Payment Flow | Backend #2 + Frontend | 3 payment flows (cash/card/online), PaymentPending status, WalletServiceClient, BFF proxy update, POS Cash/Card/QR components wired | | Account Management (Admin Settings) | Backend + Frontend | Full profile/merchant CRUD via BFF | | Subscription System | Backend + Frontend | Merchant entity + EF + API + dynamic UI | | User → Enterprise Plan | Backend + DB | hongochai10@icloud.com set to Enterprise (unlimited) | @@ -242,6 +246,10 @@ | Date | Decision | Rationale | Status | |------|----------|-----------|:------:| +| 2026-03-06 | IPaymentGateway in Domain, implementations in Infrastructure | Multiple gateways (VNPay, Momo) via same interface | ACTIVE | +| 2026-03-06 | PosHub in order-service (not separate service) | Order lifecycle owns real-time notifications | ACTIVE | +| 2026-03-06 | Kitchen→Inventory via HTTP + Polly (not message queue) | Simpler, sufficient for MVP, fire-and-forget pattern | ACTIVE | +| 2026-03-06 | 3 payment flows: cash (instant), card (instant), online (async) | Cash/card don't need gateway, only VNPay/Momo need redirect | ACTIVE | | 2026-03-06 | Subscription stored in Merchant aggregate | Simple, no separate service needed for MVP | ACTIVE | | 2026-03-06 | Static plan definitions in frontend + backend | 4 fixed tiers sufficient for MVP launch | ACTIVE | | 2026-03-05 | BFF pattern for frontend-backend proxy | Single entry point, auth forwarding, response normalization | ACTIVE | diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor index 94538e22..9bd8619b 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor @@ -124,14 +124,14 @@ else StateHasChanged(); try { - var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "card"); - if (success) + var result = await DataService.PayOrderWithDetailsAsync(_resolvedOrderId, ShopId, "card"); + if (result?.Success == true) { NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=card"); } else { - _errorMessage = "Thanh toán thẻ thất bại. Vui lòng thử lại."; + _errorMessage = result?.ErrorMessage ?? "Thanh toán thẻ thất bại. Vui lòng thử lại."; } } catch diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor index 23489130..fb1eeb5f 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor @@ -183,14 +183,16 @@ else StateHasChanged(); try { - var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "cash"); - if (success) + var result = await DataService.PayOrderWithDetailsAsync( + _resolvedOrderId, ShopId, "cash", amountTendered: _receivedAmount); + if (result?.Success == true) { - NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=cash&change={_changeAmount}"); + var change = result.ChangeAmount ?? _changeAmount; + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=cash&change={change}"); } else { - _errorMessage = "Thanh toán thất bại. Vui lòng thử lại."; + _errorMessage = result?.ErrorMessage ?? "Thanh toán thất bại. Vui lòng thử lại."; } } catch diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor index 9ab5e1e8..a1f3daa1 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor @@ -175,14 +175,35 @@ else StateHasChanged(); try { - var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "qr"); - if (success) + // EN: Map QR provider to gateway name for online payment flow. + // VI: Map nhà cung cấp QR sang tên gateway cho thanh toán trực tuyến. + var gateway = _selectedProvider.ToLowerInvariant() switch { - NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=qr"); + "momo" => "momo", + "zalopay" => "vnpay", // EN: ZaloPay routes through VNPay gateway / VI: ZaloPay đi qua cổng VNPay + _ => "qr" // EN: VietQR — POS-confirmed, no redirect / VI: VietQR — POS xác nhận, không redirect + }; + + var returnUrl = $"{NavigationManager.BaseUri}pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=qr"; + var result = await DataService.PayOrderWithDetailsAsync( + _resolvedOrderId, ShopId, gateway, returnUrl: returnUrl); + + if (result?.Success == true) + { + // EN: If payment URL returned (online gateway), redirect to payment page. + // VI: Nếu có URL thanh toán (cổng trực tuyến), redirect đến trang thanh toán. + if (!string.IsNullOrEmpty(result.PaymentUrl)) + { + NavigationManager.NavigateTo(result.PaymentUrl, forceLoad: true); + } + else + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=qr"); + } } else { - _errorMessage = "Thanh toán thất bại. Vui lòng thử lại."; + _errorMessage = result?.ErrorMessage ?? "Thanh toán thất bại. Vui lòng thử lại."; } } catch diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 5c5963c7..afe248c5 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -775,12 +775,50 @@ public class PosDataService // ═══ PAY ORDER ═══ - public async Task PayOrderAsync(Guid orderId, Guid shopId, string? paymentMethod = null) + /// + /// EN: Payment result from order-service via BFF. + /// VI: Kết quả thanh toán từ order-service qua BFF. + /// + public record PayOrderResponse(bool Success, string? Status, string? PaymentUrl, decimal? ChangeAmount, string? TransactionId, string? ErrorMessage); + + /// + /// EN: Pay an order with payment method details. Returns rich result for all payment flows. + /// VI: Thanh toán order với chi tiết phương thức. Trả về kết quả đầy đủ cho tất cả luồng thanh toán. + /// + public async Task PayOrderWithDetailsAsync( + Guid orderId, Guid shopId, string paymentMethod, + decimal? amountTendered = null, string? returnUrl = null) { AttachToken(); - var body = paymentMethod != null ? new { PaymentMethod = paymentMethod } : (object)new { }; + var body = new { PaymentMethod = paymentMethod, AmountTendered = amountTendered, ReturnUrl = returnUrl }; var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", body, _writeOptions); - return resp.IsSuccessStatusCode; + if (resp.IsSuccessStatusCode) + { + // EN: BFF proxies order-service response: { success: true, data: { ... } } + // VI: BFF proxy response từ order-service: { success: true, data: { ... } } + try + { + var json = await resp.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("data", out var data)) + { + return System.Text.Json.JsonSerializer.Deserialize(data.GetRawText(), _jsonOptions); + } + return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOptions); + } + catch { return new PayOrderResponse(true, null, null, null, null, null); } + } + return new PayOrderResponse(false, null, null, null, null, "Payment failed"); + } + + /// + /// EN: Simple pay order (backward compatible). Returns true if successful. + /// VI: Thanh toán order đơn giản (tương thích ngược). Trả về true nếu thành công. + /// + public async Task PayOrderAsync(Guid orderId, Guid shopId, string? paymentMethod = null) + { + var result = await PayOrderWithDetailsAsync(orderId, shopId, paymentMethod ?? "cash"); + return result?.Success ?? false; } // ═══ ACTIVE TABLE ORDERS ═══ diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs index eca8aca6..f63f676b 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; namespace WebClientTpos.Server.Controllers; @@ -114,14 +115,23 @@ public class OrderController : ControllerBase } /// - /// EN: Pay an order. - /// VI: Thanh toán đơn hàng. + /// EN: Pay an order — forwards payment method and details to OrderService. + /// VI: Thanh toán đơn hàng — chuyển tiếp phương thức thanh toán và chi tiết đến OrderService. /// [HttpPost("orders/{orderId:guid}/pay")] - public Task PayOrder(Guid orderId, [FromQuery] Guid? shopId = null) + public Task PayOrder( + Guid orderId, + [FromQuery] Guid? shopId = null, + [FromBody] PayOrderBffRequest? request = null) { var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; - return _order.PostAsJsonAsync($"/api/v1/orders/{orderId}/pay{qs}", new { }).ProxyAsync(); + var body = new + { + paymentMethod = request?.PaymentMethod ?? "cash", + amountTendered = request?.AmountTendered, + returnUrl = request?.ReturnUrl + }; + return _order.PostAsJsonAsync($"/api/v1/orders/{orderId}/pay{qs}", body).ProxyAsync(); } /// diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs index 633e62b7..7f8630b3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs @@ -47,5 +47,8 @@ public record UpdateTicketStatusRequest(string Status); public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List? Ingredients); public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); +// ═══ Payment ═══ +public record PayOrderBffRequest(string? PaymentMethod, decimal? AmountTendered, string? ReturnUrl); + // ═══ Reports (unused DTO kept for reference) ═══ public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue); diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml index 6f3943c2..2359b479 100644 --- a/infra/traefik/dynamic/routes.yml +++ b/infra/traefik/dynamic/routes.yml @@ -89,6 +89,53 @@ http: entryPoints: - web + # EN: Order Service - Order Management & POS API + # VI: Order Service - Quản lý Order & POS API + order-service-router: + rule: "PathPrefix(`/api/v1/orders`)" + service: order-service + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + + # EN: Order Service - POS/KDS SignalR Hub (WebSocket) + # VI: Order Service - POS/KDS SignalR Hub (WebSocket) + order-service-hub-router: + rule: "PathPrefix(`/hubs/pos`)" + service: order-service + priority: 110 + middlewares: + - cors + entryPoints: + - web + + # EN: FnB Engine - Kitchen, Sessions, Tables, Recipes, Reservations + # VI: FnB Engine - Bếp, Phiên, Bàn, Công thức, Đặt bàn + fnb-engine-router: + rule: "PathPrefix(`/api/v1/kitchen`) || PathPrefix(`/api/v1/fnb`)" + service: fnb-engine + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + + # EN: Inventory Service - Stock Management & Deduction + # VI: Inventory Service - Quản lý Tồn kho & Trừ kho + inventory-service-router: + rule: "PathPrefix(`/api/v1/inventory`)" + service: inventory-service + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + services: iam-service: loadBalancer: @@ -124,4 +171,25 @@ http: merchant-service: loadBalancer: servers: - - url: "http://merchant-service-net:8080" \ No newline at end of file + - url: "http://merchant-service-net:8080" + + # EN: Order Service (POS/KDS hub + REST API) + # VI: Order Service (POS/KDS hub + REST API) + order-service: + loadBalancer: + servers: + - url: "http://order-service-net:8080" + + # EN: FnB Engine + # VI: FnB Engine + fnb-engine: + loadBalancer: + servers: + - url: "http://fnb-engine-net:8080" + + # EN: Inventory Service + # VI: Inventory Service + inventory-service: + loadBalancer: + servers: + - url: "http://inventory-service-net:8080" \ No newline at end of file diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommand.cs index dcca5fb6..19f2b7ca 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommand.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommand.cs @@ -10,5 +10,7 @@ public record CreateKitchenTicketCommand( Guid OrderItemId, string ItemName, string? Station = null, - int Priority = 0 + int Priority = 0, + Guid? ProductId = null, + int Quantity = 1 ) : IRequest; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommandHandler.cs index f621ad10..7741daaf 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommandHandler.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/CreateKitchenTicketCommandHandler.cs @@ -20,7 +20,9 @@ public class CreateKitchenTicketCommandHandler : IRequestHandler +/// EN: Handles KitchenTicketServedDomainEvent by looking up recipe and deducting inventory. +/// VI: Xu ly KitchenTicketServedDomainEvent bang cach tra cuu cong thuc va tru kho. +/// +public class KitchenTicketServedDomainEventHandler : INotificationHandler +{ + private readonly IRecipeRepository _recipeRepository; + private readonly ISessionRepository _sessionRepository; + private readonly IInventoryServiceClient _inventoryClient; + private readonly ILogger _logger; + + public KitchenTicketServedDomainEventHandler( + IRecipeRepository recipeRepository, + ISessionRepository sessionRepository, + IInventoryServiceClient inventoryClient, + ILogger logger) + { + _recipeRepository = recipeRepository ?? throw new ArgumentNullException(nameof(recipeRepository)); + _sessionRepository = sessionRepository ?? throw new ArgumentNullException(nameof(sessionRepository)); + _inventoryClient = inventoryClient ?? throw new ArgumentNullException(nameof(inventoryClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(KitchenTicketServedDomainEvent notification, CancellationToken cancellationToken) + { + var ticket = notification.Ticket; + + _logger.LogInformation( + "EN: Kitchen ticket served, starting inventory deduction / VI: Phieu bep da phuc vu, bat dau tru kho - TicketId: {TicketId}, Product: {ProductName}", + ticket.Id, ticket.ItemName); + + try + { + // EN: Get shop ID from the session. + // VI: Lay shop ID tu session. + var session = await _sessionRepository.GetByIdAsync(ticket.SessionId, cancellationToken); + if (session == null) + { + _logger.LogWarning( + "EN: Session not found for ticket, skipping inventory deduction / VI: Khong tim thay session cho phieu, bo qua tru kho - SessionId: {SessionId}", + ticket.SessionId); + return; + } + + // EN: Look up the recipe for this product in this shop. + // VI: Tra cuu cong thuc cho san pham nay trong shop nay. + var recipe = await _recipeRepository.GetByProductIdAndShopAsync(ticket.ProductId, session.ShopId, cancellationToken); + if (recipe == null) + { + _logger.LogInformation( + "EN: No recipe found for product, skipping inventory deduction / VI: Khong tim thay cong thuc cho san pham, bo qua tru kho - ProductId: {ProductId}, ShopId: {ShopId}", + ticket.ProductId, session.ShopId); + return; + } + + // EN: Filter ingredients that have inventory item links. + // VI: Loc nguyen lieu co lien ket den inventory item. + var linkedIngredients = recipe.Ingredients + .Where(i => i.InventoryItemId.HasValue && i.InventoryItemId.Value != Guid.Empty) + .ToList(); + + if (linkedIngredients.Count == 0) + { + _logger.LogInformation( + "EN: Recipe has no linked inventory items, skipping deduction / VI: Cong thuc khong co nguyen lieu lien ket kho, bo qua tru kho - RecipeId: {RecipeId}", + recipe.Id); + return; + } + + // EN: Build deduction request. Multiply quantity per serving by ticket quantity. + // VI: Tao yeu cau tru kho. Nhan so luong moi phan voi so luong phieu. + var deductionItems = linkedIngredients.Select(ingredient => new DeductionItemDto( + ingredient.InventoryItemId!.Value, + ingredient.QuantityPerServing > 0 + ? (int)Math.Ceiling(ingredient.QuantityPerServing * ticket.Quantity) + : ticket.Quantity, + ingredient.Unit, + ingredient.IngredientName + )).ToList(); + + var request = new DeductInventoryRequest( + ShopId: session.ShopId, + ReferenceId: ticket.Id, + ReferenceType: "KitchenTicket", + Reason: $"Auto-deduction for served ticket: {ticket.ItemName} x{ticket.Quantity}", + Items: deductionItems); + + // EN: Call inventory service (fire-and-forget style - don't block kitchen flow). + // VI: Goi inventory service (kieu fire-and-forget - khong chan luong bep). + _ = Task.Run(async () => + { + try + { + var result = await _inventoryClient.DeductInventoryAsync(request, cancellationToken); + if (result) + { + _logger.LogInformation( + "EN: Inventory deduction successful / VI: Tru kho thanh cong - TicketId: {TicketId}, Items: {ItemCount}", + ticket.Id, deductionItems.Count); + } + else + { + _logger.LogWarning( + "EN: Inventory deduction returned false / VI: Tru kho tra ve false - TicketId: {TicketId}", + ticket.Id); + } + } + catch (Exception ex) + { + // EN: Log but don't throw - inventory deduction should not block kitchen flow. + // VI: Log nhung khong throw - tru kho khong nen chan luong bep. + _logger.LogError(ex, + "EN: Failed to deduct inventory / VI: Tru kho that bai - TicketId: {TicketId}, Error: {Error}", + ticket.Id, ex.Message); + } + }, cancellationToken); + } + catch (Exception ex) + { + // EN: Log but don't throw - inventory deduction should NOT block kitchen workflow. + // VI: Log nhung khong throw - tru kho KHONG nen chan luong bep. + _logger.LogError(ex, + "EN: Error during inventory deduction handling / VI: Loi trong xu ly tru kho - TicketId: {TicketId}", + ticket.Id); + } + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/Events/KitchenTicketServedIntegrationEvent.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/Events/KitchenTicketServedIntegrationEvent.cs new file mode 100644 index 00000000..d4b48e7e --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/Events/KitchenTicketServedIntegrationEvent.cs @@ -0,0 +1,19 @@ +// EN: Integration event published when a kitchen ticket is served. +// VI: Integration event phat ra khi phieu bep duoc phuc vu. + +using MediatR; + +namespace FnbEngine.API.Application.IntegrationEvents.Events; + +/// +/// EN: Integration event for cross-service communication when a kitchen ticket is served. +/// VI: Integration event cho giao tiep giua cac services khi phieu bep duoc phuc vu. +/// +public record KitchenTicketServedIntegrationEvent( + Guid TicketId, + Guid OrderItemId, + Guid ProductId, + Guid ShopId, + string ProductName, + int Quantity, + DateTime ServedAt) : INotification; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs b/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs index dd721234..cb44d17f 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Controllers/KitchenController.cs @@ -86,14 +86,16 @@ public class KitchenController : ControllerBase await _sessionRepository.UnitOfWork.SaveEntitiesAsync(ct); } - // EN: Create kitchen ticket via MediatR - // VI: Tạo phiếu bếp qua MediatR + // EN: Create kitchen ticket via MediatR (with ProductId and Quantity for inventory deduction) + // VI: Tạo phiếu bếp qua MediatR (với ProductId và Quantity cho trừ kho) var ticketId = await _mediator.Send(new CreateKitchenTicketCommand( activeSession.Id, request.ProductId, request.ProductName, null, - 0 + 0, + request.ProductId, + request.Quantity ), ct); return Ok(new CreateTicketResponse(ticketId)); diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs index 2db0528b..69894f77 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs @@ -1,6 +1,7 @@ // EN: Kitchen ticket entity for kitchen display. // VI: Entity KitchenTicket cho hiển thị bếp. +using FnbEngine.Domain.Events; using FnbEngine.Domain.SeedWork; namespace FnbEngine.Domain.AggregatesModel.KitchenAggregate; @@ -13,18 +14,32 @@ public class KitchenTicket : Entity, IAggregateRoot { private Guid _sessionId; private Guid _orderItemId; + private Guid _productId; private string _itemName = null!; private string? _station; // Bar, Kitchen, Grill, etc. private int _priority; + private int _quantity; private string _status = null!; // Pending, InProgress, Ready, Served private DateTime _createdAt; private DateTime? _completedAt; public Guid SessionId => _sessionId; public Guid OrderItemId => _orderItemId; + + /// + /// EN: Product ID from Catalog Service (for recipe lookup). + /// VI: ID san pham tu Catalog Service (de tra cuu cong thuc). + /// + public Guid ProductId => _productId; public string ItemName => _itemName; public string? Station => _station; public int Priority => _priority; + + /// + /// EN: Quantity of items ordered. + /// VI: So luong item da dat. + /// + public int Quantity => _quantity; public string Status => _status; public DateTime CreatedAt => _createdAt; public DateTime? CompletedAt => _completedAt; @@ -34,11 +49,22 @@ public class KitchenTicket : Entity, IAggregateRoot } public KitchenTicket(Guid sessionId, Guid orderItemId, string itemName, string? station = null, int priority = 0) + : this(sessionId, orderItemId, orderItemId, itemName, 1, station, priority) + { + } + + /// + /// EN: Create a kitchen ticket with product ID and quantity. + /// VI: Tao phieu bep voi product ID va so luong. + /// + public KitchenTicket(Guid sessionId, Guid orderItemId, Guid productId, string itemName, int quantity = 1, string? station = null, int priority = 0) { Id = Guid.NewGuid(); _sessionId = sessionId; _orderItemId = orderItemId; + _productId = productId; _itemName = itemName; + _quantity = quantity > 0 ? quantity : 1; _station = station; _priority = priority; _status = "Pending"; @@ -56,8 +82,16 @@ public class KitchenTicket : Entity, IAggregateRoot _completedAt = DateTime.UtcNow; } + /// + /// EN: Mark ticket as served and raise domain event for inventory deduction. + /// VI: Danh dau phieu da phuc vu va phat domain event de tru kho. + /// public void MarkAsServed() { _status = "Served"; + + // EN: Raise domain event for inventory auto-deduction. + // VI: Phat domain event de tu dong tru kho. + AddDomainEvent(new KitchenTicketServedDomainEvent(this)); } } diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/Events/KitchenTicketServedDomainEvent.cs b/services/fnb-engine-net/src/FnbEngine.Domain/Events/KitchenTicketServedDomainEvent.cs new file mode 100644 index 00000000..9a879ede --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Domain/Events/KitchenTicketServedDomainEvent.cs @@ -0,0 +1,13 @@ +// EN: Domain event raised when a kitchen ticket is marked as served. +// VI: Domain event phat ra khi phieu bep duoc danh dau da phuc vu. + +using FnbEngine.Domain.AggregatesModel.KitchenAggregate; +using MediatR; + +namespace FnbEngine.Domain.Events; + +/// +/// EN: Domain event raised when a kitchen ticket status changes to Served. +/// VI: Domain event phat ra khi trang thai phieu bep chuyen sang Served. +/// +public record KitchenTicketServedDomainEvent(KitchenTicket Ticket) : INotification; diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs index 0c584446..5804c357 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/DependencyInjection.cs @@ -6,8 +6,11 @@ using FnbEngine.Domain.AggregatesModel.SessionAggregate; using FnbEngine.Domain.AggregatesModel.KitchenAggregate; using FnbEngine.Domain.AggregatesModel.RecipeAggregate; using FnbEngine.Domain.AggregatesModel.ReservationAggregate; +using FnbEngine.Infrastructure.ExternalServices; using FnbEngine.Infrastructure.Idempotency; using FnbEngine.Infrastructure.Repositories; +using Polly; +using Polly.Extensions.Http; namespace FnbEngine.Infrastructure; @@ -60,6 +63,30 @@ public static class DependencyInjection // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); + // EN: Register inventory service HTTP client with Polly retry policy. + // VI: Đăng ký HTTP client inventory service với Polly retry policy. + var inventoryServiceUrl = configuration["Services:InventoryService:BaseUrl"] + ?? configuration["INVENTORY_SERVICE_URL"] + ?? "http://inventory-service:8080"; + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(inventoryServiceUrl); + client.Timeout = TimeSpan.FromSeconds(10); + }) + .AddPolicyHandler(HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryAttempt, context) => + { + // EN: Log retry attempts / VI: Log các lần thử lại + Console.WriteLine($"Retry {retryAttempt} for inventory deduction after {timespan.TotalSeconds}s"); + })) + .AddPolicyHandler(HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))); + return services; } } diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/KitchenTicketEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/KitchenTicketEntityTypeConfiguration.cs index c5b3f439..f8a42a27 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/KitchenTicketEntityTypeConfiguration.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/KitchenTicketEntityTypeConfiguration.cs @@ -31,6 +31,10 @@ public class KitchenTicketEntityTypeConfiguration : IEntityTypeConfiguration kt.ProductId) + .HasColumnName("product_id") + .IsRequired(); + builder.Property(kt => kt.ItemName) .HasColumnName("item_name") .HasMaxLength(200) @@ -44,6 +48,11 @@ public class KitchenTicketEntityTypeConfiguration : IEntityTypeConfiguration kt.Quantity) + .HasColumnName("quantity") + .IsRequired() + .HasDefaultValue(1); + builder.Property(kt => kt.Status) .HasColumnName("status") .HasMaxLength(50) @@ -56,6 +65,8 @@ public class KitchenTicketEntityTypeConfiguration : IEntityTypeConfiguration kt.CompletedAt) .HasColumnName("completed_at"); + builder.Ignore(kt => kt.DomainEvents); + // EN: Index for querying by session // VI: Index để query theo phiên builder.HasIndex(kt => kt.SessionId) diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/IInventoryServiceClient.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/IInventoryServiceClient.cs new file mode 100644 index 00000000..7e76470d --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/IInventoryServiceClient.cs @@ -0,0 +1,38 @@ +// EN: Interface for inventory service HTTP client. +// VI: Interface cho HTTP client cua inventory service. + +namespace FnbEngine.Infrastructure.ExternalServices; + +/// +/// EN: Client interface for calling inventory-service API. +/// VI: Interface client de goi API cua inventory-service. +/// +public interface IInventoryServiceClient +{ + /// + /// EN: Deduct inventory items (bulk deduction via inventory-service API). + /// VI: Tru kho (tru hang loat qua API inventory-service). + /// + Task DeductInventoryAsync(DeductInventoryRequest request, CancellationToken cancellationToken = default); +} + +/// +/// EN: Request DTO for bulk inventory deduction. +/// VI: DTO request cho tru kho hang loat. +/// +public record DeductInventoryRequest( + Guid ShopId, + Guid ReferenceId, + string ReferenceType, + string Reason, + List Items); + +/// +/// EN: Individual item to deduct from inventory. +/// VI: Item rieng le de tru tu kho. +/// +public record DeductionItemDto( + Guid InventoryItemId, + int Amount, + string Unit, + string IngredientName); diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/InventoryServiceClient.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/InventoryServiceClient.cs new file mode 100644 index 00000000..5219bdb8 --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/ExternalServices/InventoryServiceClient.cs @@ -0,0 +1,81 @@ +// EN: HTTP client implementation for calling inventory-service API. +// VI: Trien khai HTTP client de goi API cua inventory-service. + +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace FnbEngine.Infrastructure.ExternalServices; + +/// +/// EN: HTTP client for inventory-service with Polly retry policies. +/// VI: HTTP client cho inventory-service voi Polly retry policies. +/// +public class InventoryServiceClient : IInventoryServiceClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public InventoryServiceClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Deduct inventory by calling POST /api/v1/inventory/deduct on inventory-service. + /// VI: Tru kho bang cach goi POST /api/v1/inventory/deduct tren inventory-service. + /// + public async Task DeductInventoryAsync(DeductInventoryRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending inventory deduction request / VI: Gui yeu cau tru kho - ReferenceId: {ReferenceId}, Items: {ItemCount}", + request.ReferenceId, request.Items.Count); + + try + { + var response = await _httpClient.PostAsJsonAsync( + "api/v1/inventory/deduct", + request, + JsonOptions, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation( + "EN: Inventory deduction API call succeeded / VI: Goi API tru kho thanh cong - ReferenceId: {ReferenceId}", + request.ReferenceId); + return true; + } + + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogWarning( + "EN: Inventory deduction API call failed / VI: Goi API tru kho that bai - StatusCode: {StatusCode}, Error: {Error}", + response.StatusCode, errorContent); + + return false; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, + "EN: HTTP error calling inventory service / VI: Loi HTTP khi goi inventory service - ReferenceId: {ReferenceId}", + request.ReferenceId); + return false; + } + catch (TaskCanceledException ex) + { + _logger.LogWarning(ex, + "EN: Inventory service call timed out / VI: Goi inventory service bi timeout - ReferenceId: {ReferenceId}", + request.ReferenceId); + return false; + } + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommand.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommand.cs new file mode 100644 index 00000000..2c6bfe8f --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommand.cs @@ -0,0 +1,47 @@ +// EN: Command for bulk inventory deduction (triggered by kitchen ticket served). +// VI: Command trừ kho hàng loạt (được kích hoạt khi phiếu bếp đã phục vụ). + +using MediatR; + +namespace InventoryService.API.Application.Commands.DeductInventory; + +/// +/// EN: Command to deduct multiple inventory items in a single operation. +/// VI: Command để trừ nhiều inventory items trong một thao tác. +/// +public record DeductInventoryCommand( + Guid ShopId, + Guid ReferenceId, + string ReferenceType, + string Reason, + List Items) : IRequest; + +/// +/// EN: Individual item to deduct from inventory. +/// VI: Item riêng lẻ để trừ từ kho. +/// +public record DeductionItem( + Guid InventoryItemId, + int Amount, + string Unit, + string IngredientName); + +/// +/// EN: Result of bulk inventory deduction. +/// VI: Kết quả trừ kho hàng loạt. +/// +public record DeductInventoryResult( + bool Success, + int ItemsDeducted, + int ItemsSkipped, + List Items); + +/// +/// EN: Result for each individual deduction item. +/// VI: Kết quả cho mỗi item trừ kho riêng lẻ. +/// +public record DeductionResultItem( + Guid InventoryItemId, + string IngredientName, + bool Deducted, + string? Error); diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommandHandler.cs b/services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommandHandler.cs new file mode 100644 index 00000000..0ae4c024 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Commands/DeductInventory/DeductInventoryCommandHandler.cs @@ -0,0 +1,146 @@ +// EN: Handler for DeductInventoryCommand - performs bulk deduction with idempotency. +// VI: Handler cho DeductInventoryCommand - thực hiện trừ kho hàng loạt với idempotency. + +using InventoryService.Domain.AggregatesModel.InventoryAggregate; +using InventoryService.Infrastructure.Idempotency; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace InventoryService.API.Application.Commands.DeductInventory; + +/// +/// EN: Handles bulk inventory deduction for kitchen ticket served events. +/// VI: Xử lý trừ kho hàng loạt cho sự kiện phiếu bếp đã phục vụ. +/// +public class DeductInventoryCommandHandler : IRequestHandler +{ + private readonly IInventoryRepository _repository; + private readonly IRequestManager _requestManager; + private readonly ILogger _logger; + + public DeductInventoryCommandHandler( + IInventoryRepository repository, + IRequestManager requestManager, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _requestManager = requestManager ?? throw new ArgumentNullException(nameof(requestManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(DeductInventoryCommand request, CancellationToken cancellationToken) + { + // EN: Idempotency check - don't deduct twice for the same reference. + // VI: Kiểm tra idempotency - không trừ hai lần cho cùng một reference. + if (await _requestManager.ExistAsync(request.ReferenceId)) + { + _logger.LogWarning( + "EN: Duplicate deduction request detected, skipping / VI: Phát hiện yêu cầu trừ kho trùng lặp, bỏ qua - ReferenceId: {ReferenceId}", + request.ReferenceId); + + return new DeductInventoryResult( + Success: true, + ItemsDeducted: 0, + ItemsSkipped: request.Items.Count, + Items: request.Items.Select(i => new DeductionResultItem( + i.InventoryItemId, i.IngredientName, false, "Duplicate request")).ToList()); + } + + // EN: Record this request for idempotency. + // VI: Ghi nhận request này cho idempotency. + await _requestManager.CreateRequestForCommandAsync(request.ReferenceId); + + var resultItems = new List(); + var itemsDeducted = 0; + var itemsSkipped = 0; + + foreach (var deductionItem in request.Items) + { + try + { + var inventoryItem = await _repository.GetByIdAsync(deductionItem.InventoryItemId, cancellationToken); + + if (inventoryItem == null) + { + _logger.LogWarning( + "EN: Inventory item not found for deduction / VI: Không tìm thấy inventory item để trừ - ItemId: {ItemId}, Ingredient: {Ingredient}", + deductionItem.InventoryItemId, deductionItem.IngredientName); + + resultItems.Add(new DeductionResultItem( + deductionItem.InventoryItemId, deductionItem.IngredientName, false, "Item not found")); + itemsSkipped++; + continue; + } + + // EN: Check if sufficient stock is available. + // VI: Kiểm tra xem có đủ stock hay không. + if (inventoryItem.AvailableQuantity < deductionItem.Amount) + { + _logger.LogWarning( + "EN: Insufficient stock for deduction / VI: Không đủ stock để trừ - ItemId: {ItemId}, Available: {Available}, Requested: {Requested}", + deductionItem.InventoryItemId, inventoryItem.AvailableQuantity, deductionItem.Amount); + + // EN: Deduct what's available instead of failing entirely. + // VI: Trừ những gì có sẵn thay vì thất bại hoàn toàn. + if (inventoryItem.AvailableQuantity > 0) + { + inventoryItem.StockOut( + inventoryItem.AvailableQuantity, + $"[Partial] {request.Reason} (requested: {deductionItem.Amount}, available: {inventoryItem.AvailableQuantity})", + request.ReferenceId); + + resultItems.Add(new DeductionResultItem( + deductionItem.InventoryItemId, deductionItem.IngredientName, true, + $"Partial deduction: {inventoryItem.AvailableQuantity} of {deductionItem.Amount}")); + itemsDeducted++; + } + else + { + resultItems.Add(new DeductionResultItem( + deductionItem.InventoryItemId, deductionItem.IngredientName, false, "Insufficient stock (0 available)")); + itemsSkipped++; + } + continue; + } + + // EN: Perform the deduction. + // VI: Thực hiện trừ kho. + inventoryItem.StockOut(deductionItem.Amount, request.Reason, request.ReferenceId); + + resultItems.Add(new DeductionResultItem( + deductionItem.InventoryItemId, deductionItem.IngredientName, true, null)); + itemsDeducted++; + + _logger.LogInformation( + "EN: Deducted inventory / VI: Đã trừ kho - ItemId: {ItemId}, Amount: {Amount}, Ingredient: {Ingredient}", + deductionItem.InventoryItemId, deductionItem.Amount, deductionItem.IngredientName); + } + catch (Exception ex) + { + // EN: Log error for individual item but continue with remaining items. + // VI: Log lỗi cho item riêng lẻ nhưng tiếp tục với các items còn lại. + _logger.LogError(ex, + "EN: Error deducting inventory item / VI: Lỗi trừ inventory item - ItemId: {ItemId}", + deductionItem.InventoryItemId); + + resultItems.Add(new DeductionResultItem( + deductionItem.InventoryItemId, deductionItem.IngredientName, false, ex.Message)); + itemsSkipped++; + } + } + + // EN: Save all changes in one transaction. + // VI: Lưu tất cả thay đổi trong một transaction. + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Bulk inventory deduction completed / VI: Trừ kho hàng loạt hoàn thành - ReferenceId: {ReferenceId}, Deducted: {Deducted}, Skipped: {Skipped}", + request.ReferenceId, itemsDeducted, itemsSkipped); + + return new DeductInventoryResult( + Success: itemsDeducted > 0, + ItemsDeducted: itemsDeducted, + ItemsSkipped: itemsSkipped, + Items: resultItems); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs index 0cb5b9a3..0f7ad42e 100644 --- a/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs +++ b/services/inventory-service-net/src/InventoryService.API/Application/DTOs/InventoryDtos.cs @@ -124,6 +124,27 @@ public record StocktakeRequest(Guid ShopId, List Items); public record StocktakeItemRequest(Guid InventoryItemId, int CountedQuantity); +/// +/// EN: Request for bulk inventory deduction (from kitchen ticket served). +/// VI: Request cho trừ kho hàng loạt (từ phiếu bếp đã phục vụ). +/// +public record DeductInventoryRequest( + Guid ShopId, + Guid ReferenceId, + string ReferenceType, + string? Reason, + List Items); + +/// +/// EN: Individual deduction item in a bulk deduction request. +/// VI: Item trừ kho riêng lẻ trong yêu cầu trừ kho hàng loạt. +/// +public record DeductionItemRequest( + Guid InventoryItemId, + int Amount, + string Unit, + string IngredientName); + public class ApiResponse { public bool Success { get; set; } diff --git a/services/inventory-service-net/src/InventoryService.API/Application/Validations/DeductInventoryCommandValidator.cs b/services/inventory-service-net/src/InventoryService.API/Application/Validations/DeductInventoryCommandValidator.cs new file mode 100644 index 00000000..85408099 --- /dev/null +++ b/services/inventory-service-net/src/InventoryService.API/Application/Validations/DeductInventoryCommandValidator.cs @@ -0,0 +1,44 @@ +// EN: FluentValidation validator for DeductInventoryCommand. +// VI: FluentValidation validator cho DeductInventoryCommand. + +using FluentValidation; +using InventoryService.API.Application.Commands.DeductInventory; + +namespace InventoryService.API.Application.Validations; + +/// +/// EN: Validates DeductInventoryCommand before processing. +/// VI: Validate DeductInventoryCommand trước khi xử lý. +/// +public class DeductInventoryCommandValidator : AbstractValidator +{ + public DeductInventoryCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.ReferenceId) + .NotEmpty() + .WithMessage("Reference ID is required / Reference ID là bắt buộc"); + + RuleFor(x => x.ReferenceType) + .NotEmpty() + .WithMessage("Reference type is required / Loại tham chiếu là bắt buộc"); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage("At least one deduction item is required / Cần ít nhất một item trừ kho"); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.InventoryItemId) + .NotEmpty() + .WithMessage("Inventory item ID is required / Inventory item ID là bắt buộc"); + + item.RuleFor(i => i.Amount) + .GreaterThan(0) + .WithMessage("Amount must be positive / Số lượng phải dương"); + }); + } +} diff --git a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs index 58481322..5c09943e 100644 --- a/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs +++ b/services/inventory-service-net/src/InventoryService.API/Controllers/InventoryController.cs @@ -2,6 +2,7 @@ // VI: Controller chính cho các thao tác Inventory. using InventoryService.API.Application.Commands; +using InventoryService.API.Application.Commands.DeductInventory; using InventoryService.API.Application.DTOs; using InventoryService.API.Application.Queries; using MediatR; @@ -440,6 +441,39 @@ public class InventoryController : ControllerBase return Ok(ApiResponse>.Ok(result)); } + /// + /// EN: Bulk deduct inventory items (called by fnb-engine when kitchen ticket is served). + /// VI: Trừ kho hàng loạt (được gọi bởi fnb-engine khi phiếu bếp đã phục vụ). + /// + [HttpPost("deduct")] + [SwaggerOperation(Summary = "Bulk deduct inventory items (cross-service: kitchen ticket served)")] + [SwaggerResponse(200, "Deduction processed")] + [SwaggerResponse(400, "Invalid request")] + public async Task>> DeductInventory( + [FromBody] DeductInventoryRequest request, + CancellationToken ct = default) + { + try + { + var command = new DeductInventoryCommand( + request.ShopId, + request.ReferenceId, + request.ReferenceType, + request.Reason ?? "Kitchen ticket deduction", + request.Items.Select(i => new DeductionItem( + i.InventoryItemId, i.Amount, i.Unit, i.IngredientName)).ToList()); + + var result = await _mediator.Send(command, ct); + + return Ok(ApiResponse.Ok(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "EN: Error processing inventory deduction / VI: Lỗi xử lý trừ kho"); + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + /// /// EN: Get low stock items. /// VI: Lấy các items stock thấp. diff --git a/services/inventory-service-net/src/InventoryService.Infrastructure/InventoryContext.cs b/services/inventory-service-net/src/InventoryService.Infrastructure/InventoryContext.cs index bd446318..ac2163be 100644 --- a/services/inventory-service-net/src/InventoryService.Infrastructure/InventoryContext.cs +++ b/services/inventory-service-net/src/InventoryService.Infrastructure/InventoryContext.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Storage; using InventoryService.Domain.AggregatesModel.InventoryAggregate; using InventoryService.Domain.SeedWork; using InventoryService.Infrastructure.EntityConfigurations; +using InventoryService.Infrastructure.Idempotency; namespace InventoryService.Infrastructure; @@ -21,6 +22,7 @@ public class InventoryContext : DbContext, IUnitOfWork /// VI: Bảng Inventory items. /// public DbSet InventoryItems => Set(); + public DbSet ClientRequests => Set(); /// /// EN: Read-only access to current transaction. @@ -44,6 +46,17 @@ public class InventoryContext : DbContext, IUnitOfWork { modelBuilder.ApplyConfiguration(new InventoryItemEntityTypeConfiguration()); + // EN: Configure ClientRequest for idempotency tracking. + // VI: Cấu hình ClientRequest cho theo dõi idempotency. + modelBuilder.Entity(b => + { + b.ToTable("client_requests"); + b.HasKey(cr => cr.Id); + b.Property(cr => cr.Id).HasColumnName("id"); + b.Property(cr => cr.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + b.Property(cr => cr.Time).HasColumnName("time").IsRequired(); + }); + // EN: Ignore Enumeration types so EF Core does NOT auto-discover TypeId as a FK. // TransactionType and ItemType are DDD Enumerations resolved in-memory. // VI: Ignore các Enumeration type để EF Core KHÔNG tự phát hiện TypeId là FK. diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CancelOrderCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CancelOrderCommandHandler.cs index 06fb5ab0..1df7bf08 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/CancelOrderCommandHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CancelOrderCommandHandler.cs @@ -2,6 +2,7 @@ // VI: Handler cho CancelOrderCommand. using MediatR; +using OrderService.API.Hubs; using OrderService.Domain.AggregatesModel.OrderAggregate; using OrderService.Domain.Exceptions; @@ -14,13 +15,16 @@ namespace OrderService.API.Application.Commands; public class CancelOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; + private readonly IPosNotificationService _posNotificationService; private readonly ILogger _logger; public CancelOrderCommandHandler( IOrderRepository orderRepository, + IPosNotificationService posNotificationService, ILogger logger) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _posNotificationService = posNotificationService ?? throw new ArgumentNullException(nameof(posNotificationService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -60,6 +64,20 @@ public class CancelOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; + private readonly IPosNotificationService _posNotificationService; private readonly ILogger _logger; public CompleteOrderCommandHandler( IOrderRepository orderRepository, + IPosNotificationService posNotificationService, ILogger logger) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _posNotificationService = posNotificationService ?? throw new ArgumentNullException(nameof(posNotificationService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -59,6 +63,20 @@ public class CompleteOrderCommandHandler : IRequestHandler +/// EN: Command triggered by payment gateway callback to finalize order payment. +/// VI: Command được kích hoạt bởi callback cổng thanh toán để hoàn tất thanh toán order. +/// +public record CompleteOrderPaymentCommand( + Guid OrderId, + string GatewayTransactionId, // EN: Transaction ID from payment gateway / VI: Mã giao dịch từ cổng thanh toán + bool IsSuccess, // EN: Whether payment was successful / VI: Thanh toán có thành công không + string? GatewayResponseCode // EN: Response code from gateway / VI: Mã phản hồi từ cổng thanh toán +) : IRequest; + +/// +/// EN: Result of completing order payment. +/// VI: Kết quả hoàn tất thanh toán order. +/// +public record CompleteOrderPaymentResult( + bool Success, + string Status, + string? ErrorMessage +); diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CompleteOrderPaymentCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CompleteOrderPaymentCommandHandler.cs new file mode 100644 index 00000000..b09d6433 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CompleteOrderPaymentCommandHandler.cs @@ -0,0 +1,82 @@ +// EN: Handler for CompleteOrderPaymentCommand — finalizes payment after gateway callback. +// VI: Handler cho CompleteOrderPaymentCommand — hoàn tất thanh toán sau callback cổng thanh toán. + +using MediatR; +using OrderService.Domain.AggregatesModel.OrderAggregate; +using OrderService.Domain.Exceptions; + +namespace OrderService.API.Application.Commands; + +/// +/// EN: Handler for completing order payment after online gateway confirmation. +/// VI: Handler hoàn tất thanh toán order sau xác nhận cổng thanh toán trực tuyến. +/// +public class CompleteOrderPaymentCommandHandler : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + private readonly ILogger _logger; + + public CompleteOrderPaymentCommandHandler( + IOrderRepository orderRepository, + ILogger logger) + { + _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + CompleteOrderPaymentCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Completing payment for order {OrderId}, success: {IsSuccess}, gateway txn: {GatewayTxnId} / VI: Hoàn tất thanh toán cho order {OrderId}, thành công: {IsSuccess}, giao dịch gateway: {GatewayTxnId}", + request.OrderId, request.IsSuccess, request.GatewayTransactionId); + + // EN: Load order / VI: Load order + var order = await _orderRepository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) + { + throw new DomainException($"Order not found: {request.OrderId}"); + } + + // EN: Verify order is in PaymentPending status / VI: Xác minh order đang ở trạng thái PaymentPending + if (order.Status != OrderStatus.PaymentPending) + { + _logger.LogWarning( + "EN: Order {OrderId} is not in PaymentPending status (current: {Status}) / VI: Order {OrderId} không ở trạng thái PaymentPending (hiện tại: {Status})", + request.OrderId, order.Status.Name); + + return new CompleteOrderPaymentResult( + false, order.Status.Name, + $"Order is not awaiting payment (current status: {order.Status.Name})"); + } + + if (!request.IsSuccess) + { + // EN: Payment failed — cancel the order + // VI: Thanh toán thất bại — hủy order + order.Cancel($"Payment failed: gateway response code {request.GatewayResponseCode}"); + + await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogWarning( + "EN: Payment failed for order {OrderId}, order cancelled / VI: Thanh toán thất bại cho order {OrderId}, order đã hủy", + request.OrderId); + + return new CompleteOrderPaymentResult(false, order.Status.Name, "Payment was not successful"); + } + + // EN: Complete payment and move to processing + // VI: Hoàn tất thanh toán và chuyển sang đang xử lý + order.CompletePayment(request.GatewayTransactionId); + order.MarkAsProcessing(); + + await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Payment completed for order {OrderId}, now processing / VI: Thanh toán hoàn tất cho order {OrderId}, đang xử lý", + order.Id); + + return new CompleteOrderPaymentResult(true, order.Status.Name, null); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs index 8b4dbf71..b5fbb130 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs @@ -2,6 +2,7 @@ // VI: Handler cho CreateOrderCommand. using MediatR; +using OrderService.API.Hubs; using OrderService.Domain.AggregatesModel.OrderAggregate; using OrderService.Domain.Strategies; @@ -15,15 +16,18 @@ public class CreateOrderCommandHandler : IRequestHandler _strategies; + private readonly IPosNotificationService _posNotificationService; private readonly ILogger _logger; public CreateOrderCommandHandler( IOrderRepository orderRepository, IEnumerable strategies, + IPosNotificationService posNotificationService, ILogger logger) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _strategies = strategies ?? throw new ArgumentNullException(nameof(strategies)); + _posNotificationService = posNotificationService ?? throw new ArgumentNullException(nameof(posNotificationService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -96,6 +100,44 @@ public class CreateOrderCommandHandler : IRequestHandler new OrderItemNotificationDto( + ItemId: i.Id, + ProductId: i.ProductId, + ProductName: i.ProductName, + ProductType: i.ProductType, + Quantity: i.Quantity, + UnitPrice: i.UnitPrice, + TotalPrice: i.TotalPrice, + Status: i.Status + )).ToList().AsReadOnly(), + TotalAmount: order.TotalAmount, + CustomerId: order.CustomerId, + TableId: order.TableId, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt + ); + + await _posNotificationService.NotifyOrderCreatedAsync( + order.ShopId, orderNotification, cancellationToken); + } + catch (Exception ex) + { + // EN: Don't fail the command if notification fails + // VI: Không fail command nếu notification thất bại + _logger.LogWarning(ex, + "EN: Failed to send POS notification for order {OrderId} / " + + "VI: Gửi thông báo POS thất bại cho order {OrderId}", + order.Id); + } + return new CreateOrderResult( order.Id, order.TotalAmount, diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommand.cs index dce91acc..2b0c7674 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommand.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommand.cs @@ -1,17 +1,21 @@ -// EN: Command to pay for an order. -// VI: Command thanh toán order. +// EN: Command to pay for an order with payment method details. +// VI: Command thanh toán order với chi tiết phương thức thanh toán. using MediatR; namespace OrderService.API.Application.Commands; /// -/// EN: Command to process payment and execute order. -/// VI: Command xử lý thanh toán và thực thi order. +/// EN: Command to process payment for an order. Supports cash, card, vnpay, momo. +/// VI: Command xử lý thanh toán cho order. Hỗ trợ tiền mặt, thẻ, vnpay, momo. /// public record PayOrderCommand( Guid OrderId, - Guid ShopId + Guid ShopId, + string PaymentMethod, // EN: "cash", "card", "vnpay", "momo" / VI: "cash", "card", "vnpay", "momo" + decimal? AmountTendered, // EN: For cash payments (amount customer gave) / VI: Cho thanh toán tiền mặt (số tiền khách đưa) + string? ReturnUrl, // EN: For online payments (redirect after payment) / VI: Cho thanh toán trực tuyến (redirect sau thanh toán) + string? IpAddress // EN: For online payments (client IP) / VI: Cho thanh toán trực tuyến (IP client) ) : IRequest; /// @@ -20,5 +24,9 @@ public record PayOrderCommand( /// public record PayOrderResult( bool Success, - string Status + string Status, + string? PaymentUrl, // EN: Redirect URL for VNPay/Momo / VI: URL redirect cho VNPay/Momo + decimal? ChangeAmount, // EN: Change for cash payments / VI: Tiền thối cho thanh toán tiền mặt + string? TransactionId, // EN: Transaction ID / VI: Mã giao dịch + string? ErrorMessage // EN: Error message if failed / VI: Thông báo lỗi nếu thất bại ); diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommandHandler.cs index 1c7cd643..7a935502 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommandHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/PayOrderCommandHandler.cs @@ -1,26 +1,34 @@ -// EN: Handler for PayOrderCommand. -// VI: Handler cho PayOrderCommand. +// EN: Handler for PayOrderCommand — routes to cash, card, or online payment flow. +// VI: Handler cho PayOrderCommand — điều hướng đến thanh toán tiền mặt, thẻ, hoặc trực tuyến. using MediatR; +using OrderService.API.Hubs; using OrderService.Domain.AggregatesModel.OrderAggregate; using OrderService.Domain.Exceptions; +using OrderService.Infrastructure.ExternalServices; namespace OrderService.API.Application.Commands; /// -/// EN: Handler for processing payment. -/// VI: Handler xử lý thanh toán. +/// EN: Handler for processing payment based on payment method. +/// VI: Handler xử lý thanh toán dựa trên phương thức thanh toán. /// public class PayOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; + private readonly IWalletServiceClient _walletServiceClient; + private readonly IPosNotificationService _posNotificationService; private readonly ILogger _logger; public PayOrderCommandHandler( IOrderRepository orderRepository, + IWalletServiceClient walletServiceClient, + IPosNotificationService posNotificationService, ILogger logger) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); + _walletServiceClient = walletServiceClient ?? throw new ArgumentNullException(nameof(walletServiceClient)); + _posNotificationService = posNotificationService ?? throw new ArgumentNullException(nameof(posNotificationService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -29,40 +37,181 @@ public class PayOrderCommandHandler : IRequestHandler await ProcessCashPayment(order, request, cancellationToken), + "card" => await ProcessCardPayment(order, request, cancellationToken), + "vnpay" or "momo" => await ProcessOnlinePayment(order, request, method, cancellationToken), + // EN: Default — treat "qr" and "transfer" as immediate like card (POS-confirmed) + // VI: Mặc định — xử lý "qr" và "transfer" như thẻ (POS xác nhận) + _ => await ProcessCardPayment(order, request, cancellationToken), + }; + } + + /// + /// EN: Process cash payment — instant, calculate change. + /// VI: Xử lý thanh toán tiền mặt — ngay lập tức, tính tiền thối. + /// + private async Task ProcessCashPayment( + Order order, + PayOrderCommand request, + CancellationToken cancellationToken) + { + var amountTendered = request.AmountTendered ?? order.TotalAmount; + + if (amountTendered < order.TotalAmount) + { + return new PayOrderResult( + false, order.Status.Name, null, null, null, + $"Insufficient cash: tendered {amountTendered}, required {order.TotalAmount}"); + } + + var transactionId = $"CASH-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString("N")[..8].ToUpper()}"; + var changeAmount = amountTendered - order.TotalAmount; + + // EN: Mark as paid and processing / VI: Đánh dấu đã thanh toán và đang xử lý + order.MarkAsPaid("cash", transactionId, amountTendered); order.MarkAsProcessing(); - // EN: Save changes - // VI: Lưu thay đổi await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); _logger.LogInformation( - "EN: Payment processed successfully / VI: Xử lý thanh toán thành công: {OrderId}", + "EN: Cash payment completed for order {OrderId}, change: {Change} / VI: Thanh toán tiền mặt hoàn tất cho order {OrderId}, tiền thối: {Change}", + order.Id, changeAmount); + + // EN: Send real-time payment notification / VI: Gửi thông báo thanh toán real-time + await SendPaymentNotificationAsync(order, "cash", cancellationToken); + + return new PayOrderResult(true, order.Status.Name, null, changeAmount, transactionId, null); + } + + /// + /// EN: Process card payment — instant (terminal handles actual charge). + /// VI: Xử lý thanh toán thẻ — ngay lập tức (terminal xử lý giao dịch thực). + /// + private async Task ProcessCardPayment( + Order order, + PayOrderCommand request, + CancellationToken cancellationToken) + { + var transactionId = $"CARD-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString("N")[..8].ToUpper()}"; + + // EN: Mark as paid and processing / VI: Đánh dấu đã thanh toán và đang xử lý + order.MarkAsPaid(request.PaymentMethod.ToLowerInvariant(), transactionId); + order.MarkAsProcessing(); + + await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Card payment completed for order {OrderId} / VI: Thanh toán thẻ hoàn tất cho order {OrderId}", order.Id); - return new PayOrderResult(true, order.Status.Name); + // EN: Send real-time payment notification / VI: Gửi thông báo thanh toán real-time + await SendPaymentNotificationAsync(order, request.PaymentMethod.ToLowerInvariant(), cancellationToken); + + return new PayOrderResult(true, order.Status.Name, null, null, transactionId, null); + } + + /// + /// EN: Process online payment (VNPay/Momo) — creates payment via wallet-service, returns redirect URL. + /// VI: Xử lý thanh toán trực tuyến (VNPay/Momo) — tạo payment qua wallet-service, trả về URL redirect. + /// + private async Task ProcessOnlinePayment( + Order order, + PayOrderCommand request, + string gateway, + CancellationToken cancellationToken) + { + try + { + var paymentResponse = await _walletServiceClient.CreatePaymentAsync( + order.Id, + order.TotalAmount, + gateway, + request.ReturnUrl ?? "", + request.IpAddress ?? "127.0.0.1", + cancellationToken); + + if (paymentResponse == null || string.IsNullOrEmpty(paymentResponse.PaymentUrl)) + { + return new PayOrderResult( + false, order.Status.Name, null, null, null, + "Failed to create online payment / Không thể tạo thanh toán trực tuyến"); + } + + // EN: Mark order as payment pending / VI: Đánh dấu order chờ thanh toán + order.MarkAsPaymentPending(gateway, paymentResponse.TransactionId); + + await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Online payment initiated for order {OrderId} via {Gateway}, txn: {TxnId} / VI: Thanh toán trực tuyến đã khởi tạo cho order {OrderId} qua {Gateway}, giao dịch: {TxnId}", + order.Id, gateway, paymentResponse.TransactionId); + + return new PayOrderResult( + true, order.Status.Name, paymentResponse.PaymentUrl, null, paymentResponse.TransactionId, null); + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Failed to create online payment for order {OrderId} / VI: Không thể tạo thanh toán trực tuyến cho order {OrderId}", + order.Id); + + return new PayOrderResult( + false, order.Status.Name, null, null, null, + $"Payment gateway error: {ex.Message}"); + } + } + + /// + /// EN: Send real-time payment notification to POS/KDS clients. + /// VI: Gửi thông báo thanh toán real-time đến POS/KDS clients. + /// + private async Task SendPaymentNotificationAsync( + Order order, + string paymentMethod, + CancellationToken cancellationToken) + { + try + { + var payment = new PaymentNotificationDto( + OrderId: order.Id, + Amount: order.TotalAmount, + Method: paymentMethod, + Status: "Completed"); + + await _posNotificationService.NotifyPaymentCompletedAsync( + order.ShopId, payment, cancellationToken); + + await _posNotificationService.NotifyOrderStatusChangedAsync( + order.ShopId, order.Id, "Validated", order.Status.Name, cancellationToken); + } + catch (Exception ex) + { + // EN: Don't fail the command if notification fails + // VI: Không fail command nếu notification thất bại + _logger.LogWarning(ex, + "EN: Failed to send payment notification for order {OrderId} / " + + "VI: Gửi thông báo thanh toán thất bại cho order {OrderId}", + order.Id); + } } } diff --git a/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs b/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs index c0678a10..8e28952d 100644 --- a/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs +++ b/services/order-service-net/src/OrderService.API/Application/DTOs/OrderDtos.cs @@ -13,6 +13,10 @@ public record OrderDto( Guid? CustomerId, string Status, decimal TotalAmount, + string? PaymentMethod, + string? TransactionId, + decimal? AmountTendered, + decimal? ChangeAmount, DateTime CreatedAt, DateTime? UpdatedAt, List Items diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderByIdQueryHandler.cs b/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderByIdQueryHandler.cs index 6e868e17..954cb718 100644 --- a/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderByIdQueryHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetOrderByIdQueryHandler.cs @@ -30,12 +30,16 @@ public class GetOrderByIdQueryHandler : IRequestHandler -/// EN: Validator for PayOrderCommand. -/// VI: Validator cho PayOrderCommand. +/// EN: Validator for PayOrderCommand — validates payment method and conditional fields. +/// VI: Validator cho PayOrderCommand — validate phương thức thanh toán và các trường điều kiện. /// public class PayOrderCommandValidator : AbstractValidator { + /// + /// EN: Supported payment methods. + /// VI: Các phương thức thanh toán được hỗ trợ. + /// + private static readonly string[] SupportedMethods = { "cash", "card", "vnpay", "momo", "qr", "transfer" }; + public PayOrderCommandValidator() { RuleFor(x => x.OrderId) @@ -20,5 +26,30 @@ public class PayOrderCommandValidator : AbstractValidator x.ShopId) .NotEmpty() .WithMessage("EN: Shop ID is required / VI: Shop ID là bắt buộc"); + + RuleFor(x => x.PaymentMethod) + .NotEmpty() + .WithMessage("EN: Payment method is required / VI: Phương thức thanh toán là bắt buộc") + .Must(m => SupportedMethods.Contains(m?.ToLowerInvariant())) + .WithMessage("EN: Payment method must be one of: cash, card, vnpay, momo, qr, transfer / VI: Phương thức thanh toán phải là: cash, card, vnpay, momo, qr, transfer"); + + // EN: AmountTendered required for cash payments + // VI: Số tiền khách đưa bắt buộc cho thanh toán tiền mặt + RuleFor(x => x.AmountTendered) + .NotNull() + .When(x => x.PaymentMethod?.ToLowerInvariant() == "cash") + .WithMessage("EN: Amount tendered is required for cash payments / VI: Số tiền khách đưa là bắt buộc cho thanh toán tiền mặt"); + + RuleFor(x => x.AmountTendered) + .GreaterThan(0) + .When(x => x.AmountTendered.HasValue) + .WithMessage("EN: Amount tendered must be greater than 0 / VI: Số tiền khách đưa phải lớn hơn 0"); + + // EN: ReturnUrl required for online payment gateways + // VI: ReturnUrl bắt buộc cho cổng thanh toán trực tuyến + RuleFor(x => x.ReturnUrl) + .NotEmpty() + .When(x => x.PaymentMethod?.ToLowerInvariant() is "vnpay" or "momo") + .WithMessage("EN: Return URL is required for online payments / VI: URL trả về là bắt buộc cho thanh toán trực tuyến"); } } diff --git a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs index bfe4153c..90dbd4fc 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs @@ -102,8 +102,8 @@ public class OrdersController : ControllerBase } /// - /// EN: Process payment for an order. - /// VI: Xử lý thanh toán cho order. + /// EN: Process payment for an order with payment method details. + /// VI: Xử lý thanh toán cho order với chi tiết phương thức thanh toán. /// [HttpPost("{id}/pay")] [ProducesResponseType(typeof(PayOrderResult), StatusCodes.Status200OK)] @@ -112,16 +112,62 @@ public class OrdersController : ControllerBase public async Task> PayOrder( Guid id, [FromQuery] Guid shopId, + [FromBody] PayOrderRequest? request = null, CancellationToken cancellationToken = default) { _logger.LogInformation( - "EN: Processing payment for order {OrderId} / VI: Xử lý thanh toán cho order {OrderId}", - id); + "EN: Processing {Method} payment for order {OrderId} / VI: Xử lý thanh toán {Method} cho order {OrderId}", + request?.PaymentMethod ?? "unknown", id); + + var command = new PayOrderCommand( + id, + shopId, + request?.PaymentMethod ?? "cash", + request?.AmountTendered, + request?.ReturnUrl, + HttpContext.Connection.RemoteIpAddress?.ToString()); - var command = new PayOrderCommand(id, shopId); var result = await _mediator.Send(command, cancellationToken); - return Ok(result); + if (!result.Success) + { + return BadRequest(new { success = false, error = new { code = "PAYMENT_FAILED", message = result.ErrorMessage } }); + } + + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Payment callback endpoint — called by wallet-service after payment gateway confirms. + /// VI: Endpoint callback thanh toán — được gọi bởi wallet-service sau khi cổng thanh toán xác nhận. + /// + [HttpPost("{id}/payment-callback")] + [ProducesResponseType(typeof(CompleteOrderPaymentResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PaymentCallback( + Guid id, + [FromBody] PaymentCallbackRequest request, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Payment callback for order {OrderId}, success: {IsSuccess} / VI: Callback thanh toán cho order {OrderId}, thành công: {IsSuccess}", + id, request.IsSuccess); + + var command = new CompleteOrderPaymentCommand( + id, + request.GatewayTransactionId, + request.IsSuccess, + request.GatewayResponseCode); + + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(new { success = false, error = new { code = "PAYMENT_CALLBACK_FAILED", message = result.ErrorMessage } }); + } + + return Ok(new { success = true, data = result }); } /// @@ -231,3 +277,23 @@ public class OrdersController : ControllerBase /// VI: Yêu cầu hủy order. /// public record CancelOrderRequest(string Reason); + +/// +/// EN: Request to pay an order with payment method details. +/// VI: Yêu cầu thanh toán order với chi tiết phương thức thanh toán. +/// +public record PayOrderRequest( + string? PaymentMethod, // EN: "cash", "card", "vnpay", "momo", "qr", "transfer" / VI: Phương thức thanh toán + decimal? AmountTendered, // EN: For cash payments / VI: Cho thanh toán tiền mặt + string? ReturnUrl // EN: For online payments / VI: Cho thanh toán trực tuyến +); + +/// +/// EN: Payment callback request from wallet-service. +/// VI: Yêu cầu callback thanh toán từ wallet-service. +/// +public record PaymentCallbackRequest( + string GatewayTransactionId, + bool IsSuccess, + string? GatewayResponseCode +); diff --git a/services/order-service-net/src/OrderService.API/Hubs/ClaimsUserIdProvider.cs b/services/order-service-net/src/OrderService.API/Hubs/ClaimsUserIdProvider.cs new file mode 100644 index 00000000..628e0bd0 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/ClaimsUserIdProvider.cs @@ -0,0 +1,28 @@ +// EN: Custom user ID provider for SignalR hub. +// VI: Custom user ID provider cho SignalR hub. + +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; + +namespace OrderService.API.Hubs; + +/// +/// EN: Custom user ID provider that extracts user ID from JWT claims. +/// VI: Custom user ID provider lấy user ID từ JWT claims. +/// +/// +/// EN: Enables targeting specific users across multiple connections/devices. +/// VI: Cho phép nhắm đến user cụ thể qua nhiều kết nối/thiết bị. +/// +public class ClaimsUserIdProvider : IUserIdProvider +{ + /// + public string? GetUserId(HubConnectionContext connection) + { + // EN: Try to get user ID from standard claim types + // VI: Thử lấy user ID từ các claim types chuẩn + return connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? connection.User?.FindFirst("sub")?.Value + ?? connection.User?.FindFirst("user_id")?.Value; + } +} diff --git a/services/order-service-net/src/OrderService.API/Hubs/HubDtos.cs b/services/order-service-net/src/OrderService.API/Hubs/HubDtos.cs new file mode 100644 index 00000000..93429ec6 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/HubDtos.cs @@ -0,0 +1,91 @@ +// EN: DTOs for real-time POS/KDS hub notifications. +// VI: DTOs cho thông báo real-time POS/KDS hub. + +namespace OrderService.API.Hubs; + +/// +/// EN: DTO for order notification via SignalR. +/// VI: DTO cho thông báo order qua SignalR. +/// +public record OrderNotificationDto( + Guid OrderId, + Guid ShopId, + string Status, + IReadOnlyCollection Items, + decimal TotalAmount, + Guid? CustomerId, + Guid? TableId, + DateTime CreatedAt, + DateTime? UpdatedAt +); + +/// +/// EN: DTO for order item in notification. +/// VI: DTO cho order item trong thông báo. +/// +public record OrderItemNotificationDto( + Guid ItemId, + Guid ProductId, + string ProductName, + string ProductType, + int Quantity, + decimal UnitPrice, + decimal TotalPrice, + string Status +); + +/// +/// EN: DTO for kitchen ticket notification via SignalR. +/// VI: DTO cho thông báo phiếu bếp qua SignalR. +/// +public record KitchenTicketNotificationDto( + Guid TicketId, + Guid OrderId, + IReadOnlyCollection Items, + int Priority, + string Status, + DateTime CreatedAt +); + +/// +/// EN: DTO for kitchen ticket item. +/// VI: DTO cho item trong phiếu bếp. +/// +public record KitchenTicketItemDto( + Guid ProductId, + string ProductName, + int Quantity, + string? Notes +); + +/// +/// EN: DTO for payment notification via SignalR. +/// VI: DTO cho thông báo thanh toán qua SignalR. +/// +public record PaymentNotificationDto( + Guid OrderId, + decimal Amount, + string Method, + string Status +); + +/// +/// EN: DTO for table status change notification via SignalR. +/// VI: DTO cho thông báo thay đổi trạng thái bàn qua SignalR. +/// +public record TableStatusNotificationDto( + Guid TableId, + string TableName, + string Status +); + +/// +/// EN: DTO for order status change notification via SignalR. +/// VI: DTO cho thông báo thay đổi trạng thái order qua SignalR. +/// +public record OrderStatusChangedDto( + Guid OrderId, + string OldStatus, + string NewStatus, + DateTime ChangedAt +); diff --git a/services/order-service-net/src/OrderService.API/Hubs/IPosHubClient.cs b/services/order-service-net/src/OrderService.API/Hubs/IPosHubClient.cs new file mode 100644 index 00000000..c5dc4070 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/IPosHubClient.cs @@ -0,0 +1,53 @@ +// EN: Strongly-typed client interface for PosHub. +// VI: Interface client strongly-typed cho PosHub. + +namespace OrderService.API.Hubs; + +/// +/// EN: Strongly-typed SignalR client interface for POS/KDS real-time updates. +/// VI: Interface SignalR client strongly-typed cho POS/KDS real-time updates. +/// +public interface IPosHubClient +{ + /// + /// EN: Notify clients that a new order was created. + /// VI: Thông báo clients rằng order mới đã được tạo. + /// + Task OrderCreated(OrderNotificationDto order); + + /// + /// EN: Notify clients that an order was updated. + /// VI: Thông báo clients rằng order đã được cập nhật. + /// + Task OrderUpdated(OrderNotificationDto order); + + /// + /// EN: Notify clients that an order status changed. + /// VI: Thông báo clients rằng trạng thái order đã thay đổi. + /// + Task OrderStatusChanged(OrderStatusChangedDto statusChange); + + /// + /// EN: Notify KDS clients that a new kitchen ticket was created. + /// VI: Thông báo KDS clients rằng phiếu bếp mới đã được tạo. + /// + Task KitchenTicketCreated(KitchenTicketNotificationDto ticket); + + /// + /// EN: Notify KDS clients that a kitchen ticket was updated. + /// VI: Thông báo KDS clients rằng phiếu bếp đã được cập nhật. + /// + Task KitchenTicketUpdated(KitchenTicketNotificationDto ticket); + + /// + /// EN: Notify clients that payment was completed. + /// VI: Thông báo clients rằng thanh toán đã hoàn thành. + /// + Task PaymentCompleted(PaymentNotificationDto payment); + + /// + /// EN: Notify clients that a table status changed. + /// VI: Thông báo clients rằng trạng thái bàn đã thay đổi. + /// + Task TableStatusChanged(TableStatusNotificationDto tableStatus); +} diff --git a/services/order-service-net/src/OrderService.API/Hubs/IPosNotificationService.cs b/services/order-service-net/src/OrderService.API/Hubs/IPosNotificationService.cs new file mode 100644 index 00000000..2f225cdb --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/IPosNotificationService.cs @@ -0,0 +1,53 @@ +// EN: Interface for POS real-time notification service. +// VI: Interface cho dịch vụ thông báo real-time POS. + +namespace OrderService.API.Hubs; + +/// +/// EN: Service interface for sending real-time notifications to POS/KDS clients. +/// VI: Interface dịch vụ gửi thông báo real-time đến POS/KDS clients. +/// +public interface IPosNotificationService +{ + /// + /// EN: Notify shop group that a new order was created. + /// VI: Thông báo group shop rằng order mới đã được tạo. + /// + Task NotifyOrderCreatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default); + + /// + /// EN: Notify shop group that an order was updated. + /// VI: Thông báo group shop rằng order đã được cập nhật. + /// + Task NotifyOrderUpdatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default); + + /// + /// EN: Notify shop group that an order status changed. + /// VI: Thông báo group shop rằng trạng thái order đã thay đổi. + /// + Task NotifyOrderStatusChangedAsync(Guid shopId, Guid orderId, string oldStatus, string newStatus, CancellationToken cancellationToken = default); + + /// + /// EN: Notify KDS group that a new kitchen ticket was created. + /// VI: Thông báo group KDS rằng phiếu bếp mới đã được tạo. + /// + Task NotifyKitchenTicketCreatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default); + + /// + /// EN: Notify KDS group that a kitchen ticket was updated. + /// VI: Thông báo group KDS rằng phiếu bếp đã được cập nhật. + /// + Task NotifyKitchenTicketUpdatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default); + + /// + /// EN: Notify shop group that payment was completed. + /// VI: Thông báo group shop rằng thanh toán đã hoàn thành. + /// + Task NotifyPaymentCompletedAsync(Guid shopId, PaymentNotificationDto payment, CancellationToken cancellationToken = default); + + /// + /// EN: Notify shop group that a table status changed. + /// VI: Thông báo group shop rằng trạng thái bàn đã thay đổi. + /// + Task NotifyTableStatusChangedAsync(Guid shopId, TableStatusNotificationDto tableStatus, CancellationToken cancellationToken = default); +} diff --git a/services/order-service-net/src/OrderService.API/Hubs/PosConnectionManager.cs b/services/order-service-net/src/OrderService.API/Hubs/PosConnectionManager.cs new file mode 100644 index 00000000..57bc699d --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/PosConnectionManager.cs @@ -0,0 +1,92 @@ +// EN: Connection manager for POS hub - tracks connected users per shop. +// VI: Connection manager cho POS hub - theo dõi users kết nối theo shop. + +using System.Collections.Concurrent; + +namespace OrderService.API.Hubs; + +/// +/// EN: Thread-safe connection manager for POS/KDS hub connections. +/// VI: Connection manager thread-safe cho POS/KDS hub connections. +/// +public class PosConnectionManager +{ + // EN: Maps ConnectionId -> ShopId for quick lookup on disconnect + // VI: Maps ConnectionId -> ShopId để tra cứu nhanh khi disconnect + private readonly ConcurrentDictionary _connectionToShop = new(); + + // EN: Maps ShopId -> Set of ConnectionIds for shop-level queries + // VI: Maps ShopId -> Set ConnectionIds cho truy vấn cấp shop + private readonly ConcurrentDictionary> _shopConnections = new(); + + /// + /// EN: Register a connection for a shop. + /// VI: Đăng ký kết nối cho shop. + /// + public void AddConnection(string connectionId, Guid shopId) + { + _connectionToShop[connectionId] = shopId; + + var connections = _shopConnections.GetOrAdd(shopId, _ => new ConcurrentDictionary()); + connections[connectionId] = 0; + } + + /// + /// EN: Remove a connection and return the associated shop ID. + /// VI: Xóa kết nối và trả về shop ID liên kết. + /// + public Guid? RemoveConnection(string connectionId) + { + if (!_connectionToShop.TryRemove(connectionId, out var shopId)) + { + return null; + } + + if (_shopConnections.TryGetValue(shopId, out var connections)) + { + connections.TryRemove(connectionId, out _); + + // EN: Clean up empty shop entries / VI: Dọn dẹp các shop không còn kết nối + if (connections.IsEmpty) + { + _shopConnections.TryRemove(shopId, out _); + } + } + + return shopId; + } + + /// + /// EN: Get all connection IDs for a specific shop. + /// VI: Lấy tất cả connection IDs cho shop cụ thể. + /// + public IReadOnlyCollection GetShopConnections(Guid shopId) + { + if (_shopConnections.TryGetValue(shopId, out var connections)) + { + return connections.Keys.ToList().AsReadOnly(); + } + + return Array.Empty(); + } + + /// + /// EN: Get the number of active connections for a shop. + /// VI: Lấy số lượng kết nối active cho shop. + /// + public int GetShopConnectionCount(Guid shopId) + { + if (_shopConnections.TryGetValue(shopId, out var connections)) + { + return connections.Count; + } + + return 0; + } + + /// + /// EN: Get total number of active connections. + /// VI: Lấy tổng số kết nối active. + /// + public int TotalConnections => _connectionToShop.Count; +} diff --git a/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs b/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs new file mode 100644 index 00000000..9e8839b0 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs @@ -0,0 +1,203 @@ +// EN: Real-time SignalR hub for POS and KDS updates. +// VI: SignalR hub thời gian thực cho POS và KDS. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace OrderService.API.Hubs; + +/// +/// EN: Real-time SignalR hub for POS and KDS updates. +/// VI: SignalR hub thời gian thực cho POS và KDS. +/// +/// +/// EN: Group naming convention: +/// - shop:{shopId} — all POS updates for a shop +/// - kds:{shopId} — kitchen display system updates only +/// - pos:{shopId} — POS terminal updates only +/// VI: Quy ước đặt tên group: +/// - shop:{shopId} — tất cả cập nhật POS cho shop +/// - kds:{shopId} — chỉ cập nhật hệ thống hiển thị bếp +/// - pos:{shopId} — chỉ cập nhật thiết bị POS +/// +[Authorize] +public class PosHub : Hub +{ + private readonly PosConnectionManager _connectionManager; + private readonly ILogger _logger; + + public PosHub( + PosConnectionManager connectionManager, + ILogger logger) + { + _connectionManager = connectionManager; + _logger = logger; + } + + #region Connection Lifecycle + + /// + /// EN: Called when a new connection is established. + /// VI: Được gọi khi kết nối mới được thiết lập. + /// + public override async Task OnConnectedAsync() + { + var userId = Context.UserIdentifier; + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning( + "EN: POS hub connection attempted without user identifier / " + + "VI: POS hub kết nối mà không có user identifier"); + await base.OnConnectedAsync(); + return; + } + + _logger.LogInformation( + "EN: User {UserId} connected to POS hub with connection {ConnectionId} / " + + "VI: User {UserId} kết nối POS hub với connection {ConnectionId}", + userId, Context.ConnectionId); + + await base.OnConnectedAsync(); + } + + /// + /// EN: Called when a connection is terminated. + /// VI: Được gọi khi kết nối bị ngắt. + /// + public override async Task OnDisconnectedAsync(Exception? exception) + { + var shopId = _connectionManager.RemoveConnection(Context.ConnectionId); + + if (shopId.HasValue) + { + _logger.LogInformation( + "EN: Connection {ConnectionId} disconnected from shop {ShopId} / " + + "VI: Connection {ConnectionId} ngắt kết nối khỏi shop {ShopId}", + Context.ConnectionId, shopId.Value); + } + + if (exception != null) + { + _logger.LogError(exception, + "EN: POS hub connection {ConnectionId} disconnected with error / " + + "VI: POS hub connection {ConnectionId} ngắt kết nối với lỗi", + Context.ConnectionId); + } + + await base.OnDisconnectedAsync(exception); + } + + #endregion + + #region Group Management + + /// + /// EN: Join a shop group to receive all POS updates for that shop. + /// VI: Tham gia group shop để nhận tất cả cập nhật POS cho shop đó. + /// + public async Task JoinShop(Guid shopId) + { + var userId = Context.UserIdentifier + ?? throw new HubException("User not authenticated / User chưa xác thực"); + + _logger.LogInformation( + "EN: User {UserId} joining shop {ShopId} / " + + "VI: User {UserId} tham gia shop {ShopId}", + userId, shopId); + + _connectionManager.AddConnection(Context.ConnectionId, shopId); + + await Groups.AddToGroupAsync(Context.ConnectionId, $"shop:{shopId}"); + } + + /// + /// EN: Join a KDS group to receive kitchen updates only. + /// VI: Tham gia group KDS để nhận cập nhật bếp. + /// + public async Task JoinKds(Guid shopId) + { + var userId = Context.UserIdentifier + ?? throw new HubException("User not authenticated / User chưa xác thực"); + + _logger.LogInformation( + "EN: User {UserId} joining KDS for shop {ShopId} / " + + "VI: User {UserId} tham gia KDS cho shop {ShopId}", + userId, shopId); + + _connectionManager.AddConnection(Context.ConnectionId, shopId); + + await Groups.AddToGroupAsync(Context.ConnectionId, $"kds:{shopId}"); + } + + /// + /// EN: Join a POS terminal group. + /// VI: Tham gia group thiết bị POS. + /// + public async Task JoinPos(Guid shopId) + { + var userId = Context.UserIdentifier + ?? throw new HubException("User not authenticated / User chưa xác thực"); + + _logger.LogInformation( + "EN: User {UserId} joining POS terminal for shop {ShopId} / " + + "VI: User {UserId} tham gia thiết bị POS cho shop {ShopId}", + userId, shopId); + + _connectionManager.AddConnection(Context.ConnectionId, shopId); + + await Groups.AddToGroupAsync(Context.ConnectionId, $"pos:{shopId}"); + } + + /// + /// EN: Leave a shop group. + /// VI: Rời group shop. + /// + public async Task LeaveShop(Guid shopId) + { + var userId = Context.UserIdentifier + ?? throw new HubException("User not authenticated / User chưa xác thực"); + + _logger.LogInformation( + "EN: User {UserId} leaving shop {ShopId} / " + + "VI: User {UserId} rời shop {ShopId}", + userId, shopId); + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"shop:{shopId}"); + } + + /// + /// EN: Leave a KDS group. + /// VI: Rời group KDS. + /// + public async Task LeaveKds(Guid shopId) + { + var userId = Context.UserIdentifier + ?? throw new HubException("User not authenticated / User chưa xác thực"); + + _logger.LogInformation( + "EN: User {UserId} leaving KDS for shop {ShopId} / " + + "VI: User {UserId} rời KDS cho shop {ShopId}", + userId, shopId); + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"kds:{shopId}"); + } + + /// + /// EN: Leave a POS terminal group. + /// VI: Rời group thiết bị POS. + /// + public async Task LeavePos(Guid shopId) + { + var userId = Context.UserIdentifier + ?? throw new HubException("User not authenticated / User chưa xác thực"); + + _logger.LogInformation( + "EN: User {UserId} leaving POS terminal for shop {ShopId} / " + + "VI: User {UserId} rời thiết bị POS cho shop {ShopId}", + userId, shopId); + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"pos:{shopId}"); + } + + #endregion +} diff --git a/services/order-service-net/src/OrderService.API/Hubs/PosNotificationService.cs b/services/order-service-net/src/OrderService.API/Hubs/PosNotificationService.cs new file mode 100644 index 00000000..fce4e4cc --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Hubs/PosNotificationService.cs @@ -0,0 +1,155 @@ +// EN: Implementation of POS real-time notification service using SignalR. +// VI: Implementation dịch vụ thông báo real-time POS sử dụng SignalR. + +using Microsoft.AspNetCore.SignalR; + +namespace OrderService.API.Hubs; + +/// +/// EN: Sends real-time notifications to POS/KDS clients via SignalR hub context. +/// VI: Gửi thông báo real-time đến POS/KDS clients qua SignalR hub context. +/// +public class PosNotificationService : IPosNotificationService +{ + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + public PosNotificationService( + IHubContext hubContext, + ILogger logger) + { + _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task NotifyOrderCreatedAsync( + Guid shopId, + OrderNotificationDto order, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending OrderCreated notification for order {OrderId} to shop {ShopId} / " + + "VI: Gửi thông báo OrderCreated cho order {OrderId} đến shop {ShopId}", + order.OrderId, shopId); + + // EN: Send to both shop and pos groups / VI: Gửi đến cả group shop và pos + await Task.WhenAll( + _hubContext.Clients.Group($"shop:{shopId}").OrderCreated(order), + _hubContext.Clients.Group($"pos:{shopId}").OrderCreated(order) + ); + } + + /// + public async Task NotifyOrderUpdatedAsync( + Guid shopId, + OrderNotificationDto order, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending OrderUpdated notification for order {OrderId} to shop {ShopId} / " + + "VI: Gửi thông báo OrderUpdated cho order {OrderId} đến shop {ShopId}", + order.OrderId, shopId); + + await _hubContext.Clients.Group($"shop:{shopId}").OrderUpdated(order); + } + + /// + public async Task NotifyOrderStatusChangedAsync( + Guid shopId, + Guid orderId, + string oldStatus, + string newStatus, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending OrderStatusChanged notification for order {OrderId}: {OldStatus} -> {NewStatus} / " + + "VI: Gửi thông báo OrderStatusChanged cho order {OrderId}: {OldStatus} -> {NewStatus}", + orderId, oldStatus, newStatus); + + var statusChange = new OrderStatusChangedDto( + orderId, + oldStatus, + newStatus, + DateTime.UtcNow); + + // EN: Send to all groups (shop, pos, kds) / VI: Gửi đến tất cả groups (shop, pos, kds) + await Task.WhenAll( + _hubContext.Clients.Group($"shop:{shopId}").OrderStatusChanged(statusChange), + _hubContext.Clients.Group($"pos:{shopId}").OrderStatusChanged(statusChange), + _hubContext.Clients.Group($"kds:{shopId}").OrderStatusChanged(statusChange) + ); + } + + /// + public async Task NotifyKitchenTicketCreatedAsync( + Guid shopId, + KitchenTicketNotificationDto ticket, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending KitchenTicketCreated notification for ticket {TicketId} to KDS shop {ShopId} / " + + "VI: Gửi thông báo KitchenTicketCreated cho phiếu {TicketId} đến KDS shop {ShopId}", + ticket.TicketId, shopId); + + // EN: Send to both shop and kds groups / VI: Gửi đến cả group shop và kds + await Task.WhenAll( + _hubContext.Clients.Group($"shop:{shopId}").KitchenTicketCreated(ticket), + _hubContext.Clients.Group($"kds:{shopId}").KitchenTicketCreated(ticket) + ); + } + + /// + public async Task NotifyKitchenTicketUpdatedAsync( + Guid shopId, + KitchenTicketNotificationDto ticket, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending KitchenTicketUpdated notification for ticket {TicketId} to KDS shop {ShopId} / " + + "VI: Gửi thông báo KitchenTicketUpdated cho phiếu {TicketId} đến KDS shop {ShopId}", + ticket.TicketId, shopId); + + await Task.WhenAll( + _hubContext.Clients.Group($"shop:{shopId}").KitchenTicketUpdated(ticket), + _hubContext.Clients.Group($"kds:{shopId}").KitchenTicketUpdated(ticket) + ); + } + + /// + public async Task NotifyPaymentCompletedAsync( + Guid shopId, + PaymentNotificationDto payment, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending PaymentCompleted notification for order {OrderId} to shop {ShopId} / " + + "VI: Gửi thông báo PaymentCompleted cho order {OrderId} đến shop {ShopId}", + payment.OrderId, shopId); + + // EN: Send to both shop and pos groups / VI: Gửi đến cả group shop và pos + await Task.WhenAll( + _hubContext.Clients.Group($"shop:{shopId}").PaymentCompleted(payment), + _hubContext.Clients.Group($"pos:{shopId}").PaymentCompleted(payment) + ); + } + + /// + public async Task NotifyTableStatusChangedAsync( + Guid shopId, + TableStatusNotificationDto tableStatus, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Sending TableStatusChanged notification for table {TableId} to shop {ShopId} / " + + "VI: Gửi thông báo TableStatusChanged cho bàn {TableId} đến shop {ShopId}", + tableStatus.TableId, shopId); + + // EN: Send to all groups / VI: Gửi đến tất cả groups + await Task.WhenAll( + _hubContext.Clients.Group($"shop:{shopId}").TableStatusChanged(tableStatus), + _hubContext.Clients.Group($"pos:{shopId}").TableStatusChanged(tableStatus), + _hubContext.Clients.Group($"kds:{shopId}").TableStatusChanged(tableStatus) + ); + } +} diff --git a/services/order-service-net/src/OrderService.API/OrderService.API.csproj b/services/order-service-net/src/OrderService.API/OrderService.API.csproj index 34339408..9d04d23b 100644 --- a/services/order-service-net/src/OrderService.API/OrderService.API.csproj +++ b/services/order-service-net/src/OrderService.API/OrderService.API.csproj @@ -36,6 +36,10 @@ + + + + diff --git a/services/order-service-net/src/OrderService.API/Program.cs b/services/order-service-net/src/OrderService.API/Program.cs index dbda4b63..83f76fe5 100644 --- a/services/order-service-net/src/OrderService.API/Program.cs +++ b/services/order-service-net/src/OrderService.API/Program.cs @@ -2,15 +2,18 @@ using Microsoft.EntityFrameworkCore; using System.Data; using FluentValidation; using Hellang.Middleware.ProblemDetails; +using Microsoft.AspNetCore.SignalR; using Npgsql; using OrderService.API.Application.Behaviors; using OrderService.API.Application.Strategies; +using OrderService.API.Hubs; using OrderService.API.Infrastructure.HttpClients; using OrderService.Domain.Strategies; using OrderService.Infrastructure; using Polly; using Polly.Extensions.Http; using Serilog; +using StackExchange.Redis; // EN: Configure Serilog early / VI: Cấu hình Serilog sớm Log.Logger = new LoggerConfiguration() @@ -83,6 +86,14 @@ try .AddPolicyHandler(retryPolicy) .AddPolicyHandler(circuitBreakerPolicy); + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri(builder.Configuration["Services:WalletService"] ?? "http://wallet-service-net:8080"); + client.Timeout = TimeSpan.FromSeconds(15); + }) + .AddPolicyHandler(retryPolicy) + .AddPolicyHandler(circuitBreakerPolicy); + // EN: Register Strategies / VI: Đăng ký Strategies builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -101,6 +112,45 @@ try // EN: Add FluentValidation / VI: Thêm FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); + // EN: Add SignalR with Redis Backplane and MessagePack for POS/KDS real-time updates + // VI: Thêm SignalR với Redis Backplane và MessagePack cho POS/KDS real-time updates + var redisConnection = builder.Configuration.GetConnectionString("Redis") + ?? builder.Configuration["Redis:ConnectionString"] + ?? "localhost:6379"; + var signalRBuilder = builder.Services.AddSignalR(options => + { + options.EnableDetailedErrors = builder.Environment.IsDevelopment(); + options.KeepAliveInterval = TimeSpan.FromSeconds( + builder.Configuration.GetValue("SignalR:KeepAliveInterval", 15)); + options.ClientTimeoutInterval = TimeSpan.FromSeconds( + builder.Configuration.GetValue("SignalR:ClientTimeoutInterval", 30)); + + // EN: Enable Stateful Reconnect (.NET 8+) / VI: Bật Stateful Reconnect + options.StatefulReconnectBufferSize = + builder.Configuration.GetValue("SignalR:StatefulReconnectBufferSize", 32768); + }); + + // EN: Add Redis Backplane for scaling / VI: Thêm Redis Backplane để scale + signalRBuilder.AddStackExchangeRedis(redisConnection, options => + { + options.Configuration.ChannelPrefix = RedisChannel.Literal("OrderService"); + options.Configuration.AbortOnConnectFail = false; + }); + + // EN: Add MessagePack protocol for better performance / VI: Thêm MessagePack cho hiệu năng tốt hơn + if (builder.Configuration.GetValue("SignalR:EnableMessagePack", true)) + { + signalRBuilder.AddMessagePackProtocol(); + } + + // EN: Add custom User ID provider / VI: Thêm custom User ID provider + builder.Services.AddSingleton(); + + // EN: Register POS notification service and connection manager + // VI: Đăng ký POS notification service và connection manager + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + // EN: Add controllers / VI: Thêm controllers builder.Services.AddControllers(); @@ -130,7 +180,11 @@ try ?? builder.Configuration["DATABASE_URL"] ?? "", name: "postgresql", - tags: ["db", "postgresql"]); + tags: ["db", "postgresql"]) + .AddRedis( + redisConnection, + name: "redis", + tags: ["cache", "redis"]); // EN: Add JWT Bearer authentication via IAM IdentityServer OIDC discovery // VI: Thêm JWT Bearer authentication qua IAM IdentityServer OIDC discovery @@ -146,17 +200,37 @@ try ValidateAudience = false, ValidateLifetime = true, }; + + // EN: Allow SignalR to receive JWT from query string (WebSocket can't use headers) + // VI: Cho phép SignalR nhận JWT từ query string (WebSocket không thể dùng headers) + options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; }); builder.Services.AddAuthorization(); - // EN: Add CORS / VI: Thêm CORS + // EN: Add CORS (AllowCredentials required for SignalR WebSocket) + // VI: Thêm CORS (AllowCredentials bắt buộc cho SignalR WebSocket) builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin() + policy.WithOrigins( + builder.Configuration.GetSection("AllowedOrigins").Get() + ?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"]) .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .AllowCredentials(); // EN: Required for SignalR / VI: Bắt buộc cho SignalR }); }); @@ -192,6 +266,12 @@ try // EN: Map controllers / VI: Map controllers app.MapControllers(); + // EN: Map SignalR POS Hub / VI: Map SignalR POS Hub + app.MapHub("/hubs/pos", options => + { + options.AllowStatefulReconnects = true; + }); + // EN: Run the application / VI: Chạy ứng dụng // EN: Auto-apply EF Core migrations on startup // VI: Tự động áp dụng EF Core migrations khi khởi động diff --git a/services/order-service-net/src/OrderService.API/appsettings.json b/services/order-service-net/src/OrderService.API/appsettings.json index c776a3fa..60f9244e 100644 --- a/services/order-service-net/src/OrderService.API/appsettings.json +++ b/services/order-service-net/src/OrderService.API/appsettings.json @@ -35,6 +35,17 @@ "Redis": { "ConnectionString": "localhost:6379" }, + "SignalR": { + "KeepAliveInterval": 15, + "ClientTimeoutInterval": 30, + "StatefulReconnectBufferSize": 32768, + "EnableMessagePack": true + }, + "AllowedOrigins": [ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:5000" + ], "Jwt": { "Secret": "your-super-secret-key-min-32-characters", "Issuer": "goodgo-platform", @@ -42,5 +53,8 @@ "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 }, + "Services": { + "WalletService": "http://wallet-service-net:8080" + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs index c0e33a9c..863421ab 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs @@ -25,6 +25,11 @@ public class Order : Entity, IAggregateRoot private string? _discountType; private string? _discountReference; + private string? _paymentMethod; + private string? _transactionId; + private decimal? _amountTendered; + private decimal? _changeAmount; + private readonly List _items = new(); /// @@ -71,6 +76,30 @@ public class Order : Entity, IAggregateRoot public string? DiscountType => _discountType; public string? DiscountReference => _discountReference; + /// + /// EN: Payment method used (cash, card, vnpay, momo). + /// VI: Phương thức thanh toán đã dùng (cash, card, vnpay, momo). + /// + public string? PaymentMethod => _paymentMethod; + + /// + /// EN: External transaction ID from payment gateway or generated for cash/card. + /// VI: Mã giao dịch bên ngoài từ cổng thanh toán hoặc tạo cho tiền mặt/thẻ. + /// + public string? TransactionId => _transactionId; + + /// + /// EN: Amount tendered by customer (for cash payments). + /// VI: Số tiền khách đưa (cho thanh toán tiền mặt). + /// + public decimal? AmountTendered => _amountTendered; + + /// + /// EN: Change returned to customer (for cash payments). + /// VI: Tiền thối lại cho khách (cho thanh toán tiền mặt). + /// + public decimal? ChangeAmount => _changeAmount; + public IReadOnlyCollection Items => _items.AsReadOnly(); /// @@ -145,14 +174,55 @@ public class Order : Entity, IAggregateRoot } /// - /// EN: Mark order as paid. - /// VI: Đánh dấu đơn hàng đã thanh toán. + /// EN: Mark order as paid with payment details. + /// VI: Đánh dấu đơn hàng đã thanh toán với chi tiết thanh toán. /// - public void MarkAsPaid() + public void MarkAsPaid(string paymentMethod, string transactionId, decimal? amountTendered = null) { - if (Status != OrderStatus.Validated) + if (Status != OrderStatus.Validated && Status != OrderStatus.PaymentPending) throw new DomainException($"Cannot mark as paid order with status {Status.Name}"); + _paymentMethod = paymentMethod; + _transactionId = transactionId; + _amountTendered = amountTendered; + if (amountTendered.HasValue) + _changeAmount = amountTendered.Value - _totalAmount; + + _status = OrderStatus.Paid; + StatusId = OrderStatus.Paid.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new OrderPaidDomainEvent(this)); + } + + /// + /// EN: Mark order as payment pending (waiting for online payment gateway confirmation). + /// VI: Đánh dấu đơn hàng chờ thanh toán (đang chờ xác nhận từ cổng thanh toán trực tuyến). + /// + public void MarkAsPaymentPending(string paymentMethod, string transactionId) + { + if (Status != OrderStatus.Validated) + throw new DomainException($"Cannot mark as payment pending order with status {Status.Name}"); + + _paymentMethod = paymentMethod; + _transactionId = transactionId; + _status = OrderStatus.PaymentPending; + StatusId = OrderStatus.PaymentPending.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new OrderPaymentPendingDomainEvent(this)); + } + + /// + /// EN: Complete payment after gateway confirmation (transitions from PaymentPending to Paid). + /// VI: Hoàn tất thanh toán sau xác nhận cổng thanh toán (chuyển từ PaymentPending sang Paid). + /// + public void CompletePayment(string gatewayTransactionId) + { + if (Status != OrderStatus.PaymentPending) + throw new DomainException($"Cannot complete payment for order with status {Status.Name}"); + + _transactionId = gatewayTransactionId; _status = OrderStatus.Paid; StatusId = OrderStatus.Paid.Id; _updatedAt = DateTime.UtcNow; diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs index 82039276..195236b2 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs @@ -47,6 +47,12 @@ public class OrderStatus : Enumeration /// public static readonly OrderStatus Cancelled = new(6, nameof(Cancelled)); + /// + /// EN: Payment pending — waiting for online payment gateway confirmation (VNPay/Momo). + /// VI: Chờ thanh toán — đang chờ xác nhận từ cổng thanh toán trực tuyến (VNPay/Momo). + /// + public static readonly OrderStatus PaymentPending = new(7, nameof(PaymentPending)); + public OrderStatus(int id, string name) : base(id, name) { } diff --git a/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs b/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs index 3247ccec..c378ec7d 100644 --- a/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs +++ b/services/order-service-net/src/OrderService.Domain/Events/OrderDomainEvents.cs @@ -24,6 +24,12 @@ public record OrderPaidDomainEvent(Order Order) : INotification; /// public record OrderCompletedDomainEvent(Order Order) : INotification; +/// +/// EN: Domain event raised when an order is waiting for online payment confirmation. +/// VI: Domain event phát ra khi đơn hàng đang chờ xác nhận thanh toán trực tuyến. +/// +public record OrderPaymentPendingDomainEvent(Order Order) : INotification; + /// /// EN: Domain event raised when an order is cancelled. /// VI: Domain event phát ra khi đơn hàng bị hủy. diff --git a/services/order-service-net/src/OrderService.Infrastructure/DependencyInjection.cs b/services/order-service-net/src/OrderService.Infrastructure/DependencyInjection.cs index 1e383afb..3282047e 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/DependencyInjection.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OrderService.Domain.AggregatesModel.OrderAggregate; +using OrderService.Infrastructure.ExternalServices; using OrderService.Infrastructure.Idempotency; using OrderService.Infrastructure.Repositories; @@ -52,6 +53,9 @@ public static class DependencyInjection // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); + // EN: Register external service clients / VI: Đăng ký external service clients + services.AddScoped(); + return services; } } diff --git a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs index 4ec71957..844a8054 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -65,6 +65,22 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("discount_reference") .HasMaxLength(255); + builder.Property("_paymentMethod") + .HasColumnName("payment_method") + .HasMaxLength(50); + + builder.Property("_transactionId") + .HasColumnName("transaction_id") + .HasMaxLength(255); + + builder.Property("_amountTendered") + .HasColumnName("amount_tendered") + .HasColumnType("decimal(18,2)"); + + builder.Property("_changeAmount") + .HasColumnName("change_amount") + .HasColumnType("decimal(18,2)"); + // EN: OrderItems collection // VI: Collection OrderItems builder.OwnsMany(o => o.Items, orderItems => @@ -147,6 +163,10 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration builder.Ignore(o => o.DiscountAmount); builder.Ignore(o => o.DiscountType); builder.Ignore(o => o.DiscountReference); + builder.Ignore(o => o.PaymentMethod); + builder.Ignore(o => o.TransactionId); + builder.Ignore(o => o.AmountTendered); + builder.Ignore(o => o.ChangeAmount); builder.Ignore(o => o.CreatedAt); builder.Ignore(o => o.UpdatedAt); } diff --git a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderStatusEntityTypeConfiguration.cs b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderStatusEntityTypeConfiguration.cs index 9598aaef..adeac968 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderStatusEntityTypeConfiguration.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderStatusEntityTypeConfiguration.cs @@ -36,7 +36,8 @@ public class OrderStatusEntityTypeConfiguration : IEntityTypeConfiguration +/// EN: Client interface for communicating with wallet-service for payment processing. +/// VI: Interface client để giao tiếp với wallet-service cho xử lý thanh toán. +/// +public interface IWalletServiceClient +{ + /// + /// EN: Create a payment request via wallet-service. Returns payment URL for online gateways. + /// VI: Tạo yêu cầu thanh toán qua wallet-service. Trả về URL thanh toán cho cổng trực tuyến. + /// + /// EN: Order ID / VI: Mã đơn hàng + /// EN: Payment amount / VI: Số tiền thanh toán + /// EN: Payment gateway (vnpay, momo) / VI: Cổng thanh toán (vnpay, momo) + /// EN: URL to redirect after payment / VI: URL redirect sau thanh toán + /// EN: Client IP address / VI: Địa chỉ IP client + /// EN: Cancellation token / VI: Token hủy + /// EN: Payment creation response with URL and transaction ID / VI: Response tạo thanh toán với URL và mã giao dịch + Task CreatePaymentAsync( + Guid orderId, + decimal amount, + string gateway, + string returnUrl, + string ipAddress, + CancellationToken cancellationToken = default); +} + +/// +/// EN: Response from wallet-service payment creation. +/// VI: Response từ wallet-service khi tạo thanh toán. +/// +public record CreatePaymentResponse( + string TransactionId, + string PaymentUrl, + string Status +); diff --git a/services/order-service-net/src/OrderService.Infrastructure/ExternalServices/WalletServiceClient.cs b/services/order-service-net/src/OrderService.Infrastructure/ExternalServices/WalletServiceClient.cs new file mode 100644 index 00000000..a3e7dd23 --- /dev/null +++ b/services/order-service-net/src/OrderService.Infrastructure/ExternalServices/WalletServiceClient.cs @@ -0,0 +1,78 @@ +// EN: HttpClient-based implementation of IWalletServiceClient. +// VI: Implementation dựa trên HttpClient của IWalletServiceClient. + +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace OrderService.Infrastructure.ExternalServices; + +/// +/// EN: Wallet-service client — calls wallet-service REST API for payment creation. +/// VI: Wallet-service client — gọi wallet-service REST API để tạo thanh toán. +/// +public class WalletServiceClient : IWalletServiceClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public WalletServiceClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task CreatePaymentAsync( + Guid orderId, + decimal amount, + string gateway, + string returnUrl, + string ipAddress, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Creating payment via wallet-service: orderId={OrderId}, amount={Amount}, gateway={Gateway} / VI: Tạo thanh toán qua wallet-service: orderId={OrderId}, amount={Amount}, gateway={Gateway}", + orderId, amount, gateway); + + var request = new + { + orderId, + amount, + gateway, + returnUrl, + ipAddress, + description = $"Payment for order {orderId}" + }; + + var response = await _httpClient.PostAsJsonAsync( + "/api/v1/payments/create", + request, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "EN: Wallet-service payment creation failed: {StatusCode} - {Error} / VI: Tạo thanh toán wallet-service thất bại: {StatusCode} - {Error}", + response.StatusCode, errorContent); + return null; + } + + var result = await response.Content.ReadFromJsonAsync( + _jsonOptions, cancellationToken); + + _logger.LogInformation( + "EN: Payment created successfully: txnId={TxnId} / VI: Tạo thanh toán thành công: txnId={TxnId}", + result?.TransactionId); + + return result; + } +} diff --git a/services/order-service-net/tests/OrderService.UnitTests/Domain/OrderAggregateTests.cs b/services/order-service-net/tests/OrderService.UnitTests/Domain/OrderAggregateTests.cs index cd594595..4809bc0b 100644 --- a/services/order-service-net/tests/OrderService.UnitTests/Domain/OrderAggregateTests.cs +++ b/services/order-service-net/tests/OrderService.UnitTests/Domain/OrderAggregateTests.cs @@ -68,7 +68,7 @@ public class OrderAggregateTests // Act order.MarkAsValidated(); - order.MarkAsPaid(); + order.MarkAsPaid("cash", "TXN-TEST-001"); order.MarkAsProcessing(); order.MarkAsCompleted(); @@ -83,7 +83,7 @@ public class OrderAggregateTests var order = new Order(Guid.NewGuid()); order.AddItem(new OrderItem(Guid.NewGuid(), "Laptop", "Physical", 1, 25_000_000m)); order.MarkAsValidated(); - order.MarkAsPaid(); + order.MarkAsPaid("cash", "TXN-TEST-001"); order.MarkAsProcessing(); order.MarkAsCompleted(); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommand.cs new file mode 100644 index 00000000..e4b2c17d --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommand.cs @@ -0,0 +1,31 @@ +namespace WalletService.API.Application.Commands.Payments; + +using MediatR; + +/// +/// EN: Command to create a payment through a payment gateway. +/// VI: Command de tao thanh toan qua cong thanh toan. +/// +public record CreatePaymentCommand( + Guid OrderId, + decimal Amount, + string Currency, + string GatewayName, + string ReturnUrl, + string IpAddress +) : IRequest; + +/// +/// EN: Result of creating a payment. +/// VI: Ket qua cua viec tao thanh toan. +/// +public record CreatePaymentResult( + Guid PaymentId, + Guid OrderId, + decimal Amount, + string Currency, + string GatewayName, + string Status, + string? PaymentUrl, + DateTime CreatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommandHandler.cs new file mode 100644 index 00000000..a1f46952 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/CreatePaymentCommandHandler.cs @@ -0,0 +1,95 @@ +namespace WalletService.API.Application.Commands.Payments; + +using MediatR; +using WalletService.Domain.AggregatesModel.PaymentAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for CreatePaymentCommand. Creates a payment entity and initiates payment via gateway. +/// VI: Handler cho CreatePaymentCommand. Tao entity thanh toan va bat dau thanh toan qua cong thanh toan. +/// +public class CreatePaymentCommandHandler : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository; + private readonly IEnumerable _paymentGateways; + private readonly ILogger _logger; + + public CreatePaymentCommandHandler( + IPaymentRepository paymentRepository, + IEnumerable paymentGateways, + ILogger logger) + { + _paymentRepository = paymentRepository; + _paymentGateways = paymentGateways; + _logger = logger; + } + + public async Task Handle( + CreatePaymentCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Creating payment for order {OrderId}, gateway {GatewayName} / " + + "VI: Tao thanh toan cho don hang {OrderId}, cong thanh toan {GatewayName}", + request.OrderId, request.GatewayName); + + // EN: Find the requested payment gateway + // VI: Tim cong thanh toan duoc yeu cau + var gateway = _paymentGateways.FirstOrDefault( + g => g.GatewayName.Equals(request.GatewayName, StringComparison.OrdinalIgnoreCase)) + ?? throw new WalletDomainException( + $"Payment gateway '{request.GatewayName}' is not supported / " + + $"Cong thanh toan '{request.GatewayName}' khong duoc ho tro"); + + // EN: Create payment entity + // VI: Tao entity thanh toan + var payment = new Payment( + request.OrderId, + request.Amount, + request.Currency, + gateway.GatewayName); + + // EN: Call gateway to create payment + // VI: Goi cong thanh toan de tao thanh toan + var paymentRequest = new PaymentRequest( + request.OrderId, + request.Amount, + request.Currency, + $"Payment for order {request.OrderId}", + request.ReturnUrl, + request.IpAddress); + + var gatewayResult = await gateway.CreatePaymentAsync(paymentRequest, cancellationToken); + + if (gatewayResult.Success) + { + // EN: Mark as processing with payment URL + // VI: Danh dau dang xu ly voi URL thanh toan + payment.MarkAsProcessing(gatewayResult.PaymentUrl, gatewayResult.TransactionId); + } + else + { + // EN: Mark as failed + // VI: Danh dau that bai + payment.Fail(gatewayResult.ErrorCode, gatewayResult.ErrorMessage); + } + + _paymentRepository.Add(payment); + await _paymentRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Payment {PaymentId} created for order {OrderId}, status: {Status} / " + + "VI: Thanh toan {PaymentId} da tao cho don hang {OrderId}, trang thai: {Status}", + payment.Id, request.OrderId, payment.Status.Name); + + return new CreatePaymentResult( + payment.Id, + payment.OrderId, + payment.Amount, + payment.Currency, + payment.GatewayName, + payment.Status.Name, + payment.PaymentUrl, + payment.CreatedAt); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommand.cs new file mode 100644 index 00000000..e125da81 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommand.cs @@ -0,0 +1,24 @@ +namespace WalletService.API.Application.Commands.Payments; + +using MediatR; + +/// +/// EN: Command to process a payment gateway callback (IPN). +/// VI: Command de xu ly callback (IPN) tu cong thanh toan. +/// +public record ProcessPaymentCallbackCommand( + string GatewayName, + IDictionary Parameters +) : IRequest; + +/// +/// EN: Result of processing a payment callback. +/// VI: Ket qua xu ly callback thanh toan. +/// +public record ProcessPaymentCallbackResult( + bool Success, + Guid? PaymentId, + Guid? OrderId, + string Status, + string? ErrorMessage +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommandHandler.cs new file mode 100644 index 00000000..e76e521e --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/Payments/ProcessPaymentCallbackCommandHandler.cs @@ -0,0 +1,134 @@ +namespace WalletService.API.Application.Commands.Payments; + +using MediatR; +using WalletService.Domain.AggregatesModel.PaymentAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for ProcessPaymentCallbackCommand. Validates gateway callback and updates payment status. +/// VI: Handler cho ProcessPaymentCallbackCommand. Xac thuc callback cong thanh toan va cap nhat trang thai. +/// +public class ProcessPaymentCallbackCommandHandler + : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository; + private readonly IEnumerable _paymentGateways; + private readonly ILogger _logger; + + public ProcessPaymentCallbackCommandHandler( + IPaymentRepository paymentRepository, + IEnumerable paymentGateways, + ILogger logger) + { + _paymentRepository = paymentRepository; + _paymentGateways = paymentGateways; + _logger = logger; + } + + public async Task Handle( + ProcessPaymentCallbackCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Processing payment callback from gateway {GatewayName} / " + + "VI: Xu ly callback thanh toan tu cong thanh toan {GatewayName}", + request.GatewayName); + + // EN: Find the payment gateway + // VI: Tim cong thanh toan + var gateway = _paymentGateways.FirstOrDefault( + g => g.GatewayName.Equals(request.GatewayName, StringComparison.OrdinalIgnoreCase)) + ?? throw new WalletDomainException( + $"Payment gateway '{request.GatewayName}' is not supported / " + + $"Cong thanh toan '{request.GatewayName}' khong duoc ho tro"); + + // EN: Validate callback hash + // VI: Xac thuc hash callback + request.Parameters.TryGetValue("vnp_SecureHash", out var secureHash); + if (!gateway.ValidateCallback(request.Parameters, secureHash ?? string.Empty)) + { + _logger.LogWarning( + "EN: Invalid callback signature from {GatewayName} / " + + "VI: Chu ky callback khong hop le tu {GatewayName}", + request.GatewayName); + + return new ProcessPaymentCallbackResult( + Success: false, + PaymentId: null, + OrderId: null, + Status: "InvalidSignature", + ErrorMessage: "Invalid callback signature / Chu ky callback khong hop le"); + } + + // EN: Find the payment by transaction reference + // VI: Tim thanh toan theo tham chieu giao dich + request.Parameters.TryGetValue("vnp_TxnRef", out var txnRef); + if (!Guid.TryParse(txnRef, out var orderId)) + { + return new ProcessPaymentCallbackResult( + Success: false, + PaymentId: null, + OrderId: null, + Status: "InvalidReference", + ErrorMessage: "Invalid transaction reference / Tham chieu giao dich khong hop le"); + } + + var payment = await _paymentRepository.GetByOrderIdAsync(orderId); + if (payment == null) + { + _logger.LogWarning( + "EN: Payment not found for order {OrderId} / " + + "VI: Khong tim thay thanh toan cho don hang {OrderId}", + orderId); + + return new ProcessPaymentCallbackResult( + Success: false, + PaymentId: null, + OrderId: orderId, + Status: "NotFound", + ErrorMessage: "Payment not found / Khong tim thay thanh toan"); + } + + // EN: Process the callback based on response code + // VI: Xu ly callback dua tren ma phan hoi + request.Parameters.TryGetValue("vnp_ResponseCode", out var responseCode); + responseCode ??= "99"; + request.Parameters.TryGetValue("vnp_TransactionNo", out var transactionNo); + transactionNo ??= string.Empty; + + if (responseCode == "00") + { + // EN: Payment successful + // VI: Thanh toan thanh cong + payment.Complete(transactionNo); + + _logger.LogInformation( + "EN: Payment {PaymentId} completed for order {OrderId} / " + + "VI: Thanh toan {PaymentId} hoan thanh cho don hang {OrderId}", + payment.Id, orderId); + } + else + { + // EN: Payment failed + // VI: Thanh toan that bai + request.Parameters.TryGetValue("vnp_OrderInfo", out var errorMessage); + errorMessage ??= "Payment failed"; + payment.Fail(responseCode, errorMessage); + + _logger.LogWarning( + "EN: Payment {PaymentId} failed for order {OrderId}, code: {ErrorCode} / " + + "VI: Thanh toan {PaymentId} that bai cho don hang {OrderId}, ma: {ErrorCode}", + payment.Id, orderId, responseCode); + } + + _paymentRepository.Update(payment); + await _paymentRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return new ProcessPaymentCallbackResult( + Success: responseCode == "00", + PaymentId: payment.Id, + OrderId: orderId, + Status: payment.Status.Name, + ErrorMessage: responseCode == "00" ? null : $"VN Pay response code: {responseCode}"); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQuery.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQuery.cs new file mode 100644 index 00000000..95831628 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQuery.cs @@ -0,0 +1,34 @@ +namespace WalletService.API.Application.Queries.Payments; + +using MediatR; + +/// +/// EN: Query to get a payment by order ID. +/// VI: Query de lay thanh toan theo ma don hang. +/// +public record GetPaymentByOrderIdQuery(Guid OrderId) : IRequest; + +/// +/// EN: Query to get a payment by gateway transaction ID. +/// VI: Query de lay thanh toan theo ma giao dich cong thanh toan. +/// +public record GetPaymentByTransactionIdQuery(string TransactionId) : IRequest; + +/// +/// EN: DTO representing a payment transaction. +/// VI: DTO dai dien cho giao dich thanh toan. +/// +public record PaymentDto( + Guid PaymentId, + Guid OrderId, + decimal Amount, + string Currency, + string GatewayName, + string? TransactionId, + string? PaymentUrl, + string Status, + string? ErrorCode, + string? ErrorMessage, + DateTime CreatedAt, + DateTime? CompletedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQueryHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQueryHandler.cs new file mode 100644 index 00000000..d157eddf --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/Payments/GetPaymentQueryHandler.cs @@ -0,0 +1,99 @@ +namespace WalletService.API.Application.Queries.Payments; + +using MediatR; +using WalletService.Domain.AggregatesModel.PaymentAggregate; + +/// +/// EN: Handler for GetPaymentByOrderIdQuery. +/// VI: Handler cho GetPaymentByOrderIdQuery. +/// +public class GetPaymentByOrderIdQueryHandler : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository; + private readonly ILogger _logger; + + public GetPaymentByOrderIdQueryHandler( + IPaymentRepository paymentRepository, + ILogger logger) + { + _paymentRepository = paymentRepository; + _logger = logger; + } + + public async Task Handle( + GetPaymentByOrderIdQuery request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Getting payment for order {OrderId} / " + + "VI: Lay thanh toan cho don hang {OrderId}", + request.OrderId); + + var payment = await _paymentRepository.GetByOrderIdAsync(request.OrderId); + + return payment == null ? null : MapToDto(payment); + } + + private static PaymentDto MapToDto(Payment payment) + { + return new PaymentDto( + payment.Id, + payment.OrderId, + payment.Amount, + payment.Currency, + payment.GatewayName, + payment.TransactionId, + payment.PaymentUrl, + payment.Status.Name, + payment.ErrorCode, + payment.ErrorMessage, + payment.CreatedAt, + payment.CompletedAt); + } +} + +/// +/// EN: Handler for GetPaymentByTransactionIdQuery. +/// VI: Handler cho GetPaymentByTransactionIdQuery. +/// +public class GetPaymentByTransactionIdQueryHandler : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository; + private readonly ILogger _logger; + + public GetPaymentByTransactionIdQueryHandler( + IPaymentRepository paymentRepository, + ILogger logger) + { + _paymentRepository = paymentRepository; + _logger = logger; + } + + public async Task Handle( + GetPaymentByTransactionIdQuery request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Getting payment by transaction ID {TransactionId} / " + + "VI: Lay thanh toan theo ma giao dich {TransactionId}", + request.TransactionId); + + var payment = await _paymentRepository.GetByTransactionIdAsync(request.TransactionId); + + if (payment == null) return null; + + return new PaymentDto( + payment.Id, + payment.OrderId, + payment.Amount, + payment.Currency, + payment.GatewayName, + payment.TransactionId, + payment.PaymentUrl, + payment.Status.Name, + payment.ErrorCode, + payment.ErrorMessage, + payment.CreatedAt, + payment.CompletedAt); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Validations/CreatePaymentCommandValidator.cs b/services/wallet-service-net/src/WalletService.API/Application/Validations/CreatePaymentCommandValidator.cs new file mode 100644 index 00000000..638a89bd --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Validations/CreatePaymentCommandValidator.cs @@ -0,0 +1,64 @@ +namespace WalletService.API.Application.Validations; + +using FluentValidation; +using WalletService.API.Application.Commands.Payments; + +/// +/// EN: Validator for CreatePaymentCommand. +/// VI: Validator cho CreatePaymentCommand. +/// +public class CreatePaymentCommandValidator : AbstractValidator +{ + public CreatePaymentCommandValidator() + { + RuleFor(x => x.OrderId) + .NotEmpty() + .WithMessage("Order ID is required / Ma don hang la bat buoc"); + + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Amount must be greater than zero / So tien phai lon hon 0"); + + RuleFor(x => x.Currency) + .NotEmpty() + .WithMessage("Currency is required / Tien te la bat buoc") + .MaximumLength(10) + .WithMessage("Currency code must not exceed 10 characters / Ma tien te khong vuot qua 10 ky tu"); + + RuleFor(x => x.GatewayName) + .NotEmpty() + .WithMessage("Gateway name is required / Ten cong thanh toan la bat buoc") + .MaximumLength(50) + .WithMessage("Gateway name must not exceed 50 characters / Ten cong thanh toan khong vuot qua 50 ky tu"); + + RuleFor(x => x.ReturnUrl) + .NotEmpty() + .WithMessage("Return URL is required / URL tra ve la bat buoc") + .Must(url => Uri.TryCreate(url, UriKind.Absolute, out _)) + .WithMessage("Return URL must be a valid URL / URL tra ve phai hop le"); + + RuleFor(x => x.IpAddress) + .NotEmpty() + .WithMessage("IP address is required / Dia chi IP la bat buoc"); + } +} + +/// +/// EN: Validator for ProcessPaymentCallbackCommand. +/// VI: Validator cho ProcessPaymentCallbackCommand. +/// +public class ProcessPaymentCallbackCommandValidator : AbstractValidator +{ + public ProcessPaymentCallbackCommandValidator() + { + RuleFor(x => x.GatewayName) + .NotEmpty() + .WithMessage("Gateway name is required / Ten cong thanh toan la bat buoc"); + + RuleFor(x => x.Parameters) + .NotNull() + .WithMessage("Callback parameters are required / Cac tham so callback la bat buoc") + .Must(p => p.Count > 0) + .WithMessage("Callback parameters cannot be empty / Cac tham so callback khong duoc trong"); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Controllers/PaymentsController.cs b/services/wallet-service-net/src/WalletService.API/Controllers/PaymentsController.cs new file mode 100644 index 00000000..3635994f --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Controllers/PaymentsController.cs @@ -0,0 +1,161 @@ +namespace WalletService.API.Controllers; + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using WalletService.API.Application.Commands.Payments; +using WalletService.API.Application.Queries.Payments; + +/// +/// EN: Controller for payment gateway operations. +/// VI: Controller cho cac thao tac cong thanh toan. +/// +[ApiController] +[Route("api/v1/[controller]")] +[SwaggerTag("Payment gateway endpoints / Cac endpoint cong thanh toan")] +public class PaymentsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public PaymentsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Create a payment through a payment gateway. + /// VI: Tao thanh toan qua cong thanh toan. + /// + [HttpPost("create")] + [Authorize] + [SwaggerOperation(Summary = "Create payment", Description = "Create a payment via payment gateway (VNPay, etc.)")] + [SwaggerResponse(201, "Payment created", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request")] + public async Task CreatePayment( + [FromBody] CreatePaymentRequest request, + CancellationToken cancellationToken) + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + + var command = new CreatePaymentCommand( + request.OrderId, + request.Amount, + request.Currency ?? "VND", + request.GatewayName ?? "VNPAY", + request.ReturnUrl, + ipAddress); + + var result = await _mediator.Send(command, cancellationToken); + + return CreatedAtAction( + nameof(GetPaymentByOrderId), + new { orderId = result.OrderId }, + new ApiResponse(true, "Payment created / Thanh toan da duoc tao", result)); + } + + /// + /// EN: Get payment by order ID. + /// VI: Lay thanh toan theo ma don hang. + /// + [HttpGet("{orderId:guid}")] + [Authorize] + [SwaggerOperation(Summary = "Get payment", Description = "Get payment information by order ID")] + [SwaggerResponse(200, "Success", typeof(ApiResponse))] + [SwaggerResponse(404, "Payment not found")] + public async Task GetPaymentByOrderId( + Guid orderId, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetPaymentByOrderIdQuery(orderId), cancellationToken); + + if (result == null) + return NotFound(new ApiResponse(false, "Payment not found / Khong tim thay thanh toan")); + + return Ok(new ApiResponse(true, "Success", result)); + } + + /// + /// EN: VN Pay IPN callback endpoint (called by VN Pay server). + /// VI: Endpoint IPN callback VN Pay (duoc goi boi server VN Pay). + /// + /// + /// EN: This endpoint does NOT require authentication as it is called by VN Pay servers. + /// VI: Endpoint nay KHONG yeu cau xac thuc vi no duoc goi boi server VN Pay. + /// + [HttpGet("vnpay/callback")] + [AllowAnonymous] + [SwaggerOperation(Summary = "VN Pay IPN callback", Description = "VN Pay server calls this endpoint to notify payment status")] + [SwaggerResponse(200, "Callback processed")] + public async Task VnPayCallback(CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Received VN Pay IPN callback / " + + "VI: Nhan IPN callback tu VN Pay"); + + // EN: Collect all query parameters + // VI: Thu thap tat ca cac tham so query + var parameters = HttpContext.Request.Query + .ToDictionary(q => q.Key, q => q.Value.ToString()); + + var command = new ProcessPaymentCallbackCommand("VNPAY", parameters); + var result = await _mediator.Send(command, cancellationToken); + + // EN: VN Pay expects specific response format + // VI: VN Pay yeu cau format phan hoi cu the + if (result.Success) + { + return Ok(new { RspCode = "00", Message = "Confirm Success" }); + } + + return Ok(new { RspCode = "99", Message = result.ErrorMessage ?? "Unknown error" }); + } + + /// + /// EN: VN Pay return URL handler (customer is redirected here after payment). + /// VI: Handler URL tra ve VN Pay (khach hang duoc chuyen huong den day sau thanh toan). + /// + [HttpGet("vnpay/return")] + [AllowAnonymous] + [SwaggerOperation(Summary = "VN Pay return", Description = "Customer is redirected here after VN Pay payment")] + [SwaggerResponse(200, "Payment result")] + public async Task VnPayReturn(CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Customer returned from VN Pay / " + + "VI: Khach hang quay ve tu VN Pay"); + + // EN: Collect all query parameters + // VI: Thu thap tat ca cac tham so query + var parameters = HttpContext.Request.Query + .ToDictionary(q => q.Key, q => q.Value.ToString()); + + var command = new ProcessPaymentCallbackCommand("VNPAY", parameters); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(new ApiResponse( + result.Success, + result.Success + ? "Payment successful / Thanh toan thanh cong" + : "Payment failed / Thanh toan that bai", + result)); + } +} + +#region Request DTOs + +/// +/// EN: Request DTO for creating a payment. +/// VI: DTO yeu cau de tao thanh toan. +/// +public record CreatePaymentRequest( + Guid OrderId, + decimal Amount, + string? Currency, + string? GatewayName, + string ReturnUrl +); + +#endregion diff --git a/services/wallet-service-net/src/WalletService.API/appsettings.Development.json b/services/wallet-service-net/src/WalletService.API/appsettings.Development.json index e407ac85..18bfb9d2 100644 --- a/services/wallet-service-net/src/WalletService.API/appsettings.Development.json +++ b/services/wallet-service-net/src/WalletService.API/appsettings.Development.json @@ -15,5 +15,12 @@ "System": "Information" } } + }, + "VnPay": { + "TmnCode": "GOODGO01", + "HashSecret": "GOODGOSECRETKEYFORSANDBOX2026", + "PaymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html", + "ReturnUrl": "http://localhost:3001/payment/return", + "ApiUrl": "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction" } } \ No newline at end of file diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentGateway.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentGateway.cs new file mode 100644 index 00000000..3d74eaad --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentGateway.cs @@ -0,0 +1,55 @@ +namespace WalletService.Domain.AggregatesModel.PaymentAggregate; + +/// +/// EN: Abstraction for payment gateway integrations. +/// VI: Abstraction cho tich hop cong thanh toan. +/// +/// +/// EN: Each payment gateway (VNPay, MoMo, ZaloPay, etc.) implements this interface. +/// VI: Moi cong thanh toan (VNPay, MoMo, ZaloPay, v.v.) implement interface nay. +/// +public interface IPaymentGateway +{ + /// + /// EN: The name of this payment gateway. + /// VI: Ten cua cong thanh toan nay. + /// + string GatewayName { get; } + + /// + /// EN: Create a payment request and get the payment URL. + /// VI: Tao yeu cau thanh toan va lay URL thanh toan. + /// + /// EN: Payment request details / VI: Chi tiet yeu cau thanh toan + /// EN: Cancellation token / VI: Token huy + /// EN: Payment result with payment URL / VI: Ket qua thanh toan voi URL thanh toan + Task CreatePaymentAsync(PaymentRequest request, CancellationToken cancellationToken); + + /// + /// EN: Query the status of an existing payment transaction. + /// VI: Truy van trang thai cua giao dich thanh toan hien co. + /// + /// EN: Transaction identifier / VI: Ma giao dich + /// EN: Cancellation token / VI: Token huy + /// EN: Payment result with current status / VI: Ket qua thanh toan voi trang thai hien tai + Task QueryPaymentAsync(string transactionId, CancellationToken cancellationToken); + + /// + /// EN: Request a refund for a completed payment. + /// VI: Yeu cau hoan tien cho giao dich da hoan thanh. + /// + /// EN: Transaction identifier / VI: Ma giao dich + /// EN: Amount to refund / VI: So tien hoan + /// EN: Cancellation token / VI: Token huy + /// EN: Payment result with refund status / VI: Ket qua thanh toan voi trang thai hoan tien + Task RefundAsync(string transactionId, decimal amount, CancellationToken cancellationToken); + + /// + /// EN: Validate a callback/IPN from the payment gateway. + /// VI: Xac thuc callback/IPN tu cong thanh toan. + /// + /// EN: Callback parameters / VI: Cac tham so callback + /// EN: Secure hash from the gateway / VI: Hash bao mat tu cong thanh toan + /// EN: True if the callback is valid / VI: True neu callback hop le + bool ValidateCallback(IDictionary parameters, string secureHash); +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentRepository.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentRepository.cs new file mode 100644 index 00000000..11339b07 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/IPaymentRepository.cs @@ -0,0 +1,46 @@ +namespace WalletService.Domain.AggregatesModel.PaymentAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Repository interface for Payment aggregate. +/// VI: Interface repository cho aggregate Payment. +/// +public interface IPaymentRepository : IRepository +{ + /// + /// EN: Get payment by ID. + /// VI: Lay thanh toan theo ID. + /// + Task GetByIdAsync(Guid paymentId); + + /// + /// EN: Get payment by order ID. + /// VI: Lay thanh toan theo ma don hang. + /// + Task GetByOrderIdAsync(Guid orderId); + + /// + /// EN: Get payment by gateway transaction ID. + /// VI: Lay thanh toan theo ma giao dich cong thanh toan. + /// + Task GetByTransactionIdAsync(string transactionId); + + /// + /// EN: Get payments by order ID (an order may have multiple payment attempts). + /// VI: Lay cac thanh toan theo ma don hang (mot don hang co the co nhieu lan thanh toan). + /// + Task> GetPaymentsByOrderIdAsync(Guid orderId); + + /// + /// EN: Add a new payment. + /// VI: Them thanh toan moi. + /// + Payment Add(Payment payment); + + /// + /// EN: Update an existing payment. + /// VI: Cap nhat thanh toan hien co. + /// + void Update(Payment payment); +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/Payment.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/Payment.cs new file mode 100644 index 00000000..f13b1069 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/Payment.cs @@ -0,0 +1,218 @@ +namespace WalletService.Domain.AggregatesModel.PaymentAggregate; + +using WalletService.Domain.Events; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Payment aggregate root representing a payment transaction through a gateway. +/// VI: Aggregate root thanh toan dai dien cho giao dich thanh toan qua cong thanh toan. +/// +/// +/// EN: Tracks the lifecycle of a payment from creation through completion/failure/refund. +/// VI: Theo doi vong doi cua thanh toan tu khi tao den khi hoan thanh/that bai/hoan tien. +/// +public class Payment : Entity, IAggregateRoot +{ + private Guid _orderId; + private decimal _amount; + private string _currency; + private string _gatewayName; + private string? _transactionId; + private string? _paymentUrl; + private int _statusId; + private string? _errorCode; + private string? _errorMessage; + private DateTime _createdAt; + private DateTime? _completedAt; + + /// + /// EN: The order identifier associated with this payment. + /// VI: Ma don hang lien ket voi thanh toan nay. + /// + public Guid OrderId => _orderId; + + /// + /// EN: Payment amount. + /// VI: So tien thanh toan. + /// + public decimal Amount => _amount; + + /// + /// EN: Currency code (e.g., VND, USD). + /// VI: Ma tien te (vd: VND, USD). + /// + public string Currency => _currency; + + /// + /// EN: Name of the payment gateway used. + /// VI: Ten cong thanh toan su dung. + /// + public string GatewayName => _gatewayName; + + /// + /// EN: Transaction ID from the payment gateway. + /// VI: Ma giao dich tu cong thanh toan. + /// + public string? TransactionId => _transactionId; + + /// + /// EN: Payment URL for customer redirect. + /// VI: URL thanh toan de chuyen huong khach hang. + /// + public string? PaymentUrl => _paymentUrl; + + /// + /// EN: Payment status ID for EF Core mapping. + /// VI: ID trang thai thanh toan cho EF Core mapping. + /// + public int StatusId => _statusId; + + /// + /// EN: Payment status. + /// VI: Trang thai thanh toan. + /// + public PaymentStatus Status => PaymentStatus.FromValue(_statusId); + + /// + /// EN: Error code if payment failed. + /// VI: Ma loi neu thanh toan that bai. + /// + public string? ErrorCode => _errorCode; + + /// + /// EN: Error message if payment failed. + /// VI: Thong bao loi neu thanh toan that bai. + /// + public string? ErrorMessage => _errorMessage; + + /// + /// EN: Payment creation timestamp. + /// VI: Thoi diem tao thanh toan. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Payment completion timestamp. + /// VI: Thoi diem hoan thanh thanh toan. + /// + public DateTime? CompletedAt => _completedAt; + + /// + /// EN: EF Core parameterless constructor. + /// VI: Constructor khong tham so cho EF Core. + /// + protected Payment() + { + _currency = string.Empty; + _gatewayName = string.Empty; + } + + /// + /// EN: Create a new payment transaction. + /// VI: Tao giao dich thanh toan moi. + /// + /// EN: Order identifier / VI: Ma don hang + /// EN: Payment amount / VI: So tien thanh toan + /// EN: Currency code / VI: Ma tien te + /// EN: Payment gateway name / VI: Ten cong thanh toan + public Payment(Guid orderId, decimal amount, string currency, string gatewayName) + { + if (orderId == Guid.Empty) + throw new WalletDomainException("Order ID cannot be empty / Ma don hang khong duoc trong"); + + if (amount <= 0) + throw new WalletDomainException("Payment amount must be greater than zero / So tien thanh toan phai lon hon 0"); + + if (string.IsNullOrWhiteSpace(currency)) + throw new WalletDomainException("Currency is required / Tien te la bat buoc"); + + if (string.IsNullOrWhiteSpace(gatewayName)) + throw new WalletDomainException("Gateway name is required / Ten cong thanh toan la bat buoc"); + + Id = Guid.NewGuid(); + _orderId = orderId; + _amount = amount; + _currency = currency.ToUpperInvariant(); + _gatewayName = gatewayName; + _statusId = PaymentStatus.Pending.Id; + _createdAt = DateTime.UtcNow; + + AddDomainEvent(new PaymentCreatedDomainEvent(Id, orderId, amount, currency, gatewayName)); + } + + /// + /// EN: Mark payment as processing (gateway has accepted the request). + /// VI: Danh dau thanh toan dang xu ly (cong thanh toan da chap nhan yeu cau). + /// + /// EN: Payment URL for customer / VI: URL thanh toan cho khach hang + /// EN: Gateway transaction ID / VI: Ma giao dich cong thanh toan + public void MarkAsProcessing(string? paymentUrl, string? transactionId = null) + { + if (_statusId != PaymentStatus.Pending.Id) + throw new WalletDomainException( + $"Cannot mark payment as processing from status {Status.Name} / " + + $"Khong the danh dau thanh toan dang xu ly tu trang thai {Status.Name}"); + + _statusId = PaymentStatus.Processing.Id; + _paymentUrl = paymentUrl; + _transactionId = transactionId; + } + + /// + /// EN: Complete the payment successfully. + /// VI: Hoan thanh thanh toan thanh cong. + /// + /// EN: Gateway transaction ID / VI: Ma giao dich cong thanh toan + public void Complete(string transactionId) + { + if (_statusId != PaymentStatus.Pending.Id && _statusId != PaymentStatus.Processing.Id) + throw new WalletDomainException( + $"Cannot complete payment from status {Status.Name} / " + + $"Khong the hoan thanh thanh toan tu trang thai {Status.Name}"); + + if (string.IsNullOrWhiteSpace(transactionId)) + throw new WalletDomainException("Transaction ID is required / Ma giao dich la bat buoc"); + + _statusId = PaymentStatus.Completed.Id; + _transactionId = transactionId; + _completedAt = DateTime.UtcNow; + + AddDomainEvent(new PaymentCompletedDomainEvent(Id, _orderId, transactionId, _amount, _currency)); + } + + /// + /// EN: Mark the payment as failed. + /// VI: Danh dau thanh toan that bai. + /// + /// EN: Error code from gateway / VI: Ma loi tu cong thanh toan + /// EN: Error message / VI: Thong bao loi + public void Fail(string? errorCode, string? errorMessage) + { + if (_statusId != PaymentStatus.Pending.Id && _statusId != PaymentStatus.Processing.Id) + throw new WalletDomainException( + $"Cannot fail payment from status {Status.Name} / " + + $"Khong the danh dau that bai tu trang thai {Status.Name}"); + + _statusId = PaymentStatus.Failed.Id; + _errorCode = errorCode; + _errorMessage = errorMessage; + _completedAt = DateTime.UtcNow; + + AddDomainEvent(new PaymentFailedDomainEvent(Id, _orderId, errorCode, errorMessage)); + } + + /// + /// EN: Refund the payment. + /// VI: Hoan tien thanh toan. + /// + public void Refund() + { + if (_statusId != PaymentStatus.Completed.Id) + throw new WalletDomainException( + $"Can only refund completed payments / " + + $"Chi co the hoan tien cac thanh toan da hoan thanh"); + + _statusId = PaymentStatus.Refunded.Id; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentRequest.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentRequest.cs new file mode 100644 index 00000000..05455d01 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentRequest.cs @@ -0,0 +1,39 @@ +namespace WalletService.Domain.AggregatesModel.PaymentAggregate; + +/// +/// EN: Value object representing a payment request to a payment gateway. +/// VI: Value object dai dien cho yeu cau thanh toan den cong thanh toan. +/// +/// EN: The order identifier / VI: Ma don hang +/// EN: Payment amount / VI: So tien thanh toan +/// EN: Currency code (e.g., VND) / VI: Ma tien te (vd: VND) +/// EN: Payment description / VI: Mo ta thanh toan +/// EN: URL to redirect after payment / VI: URL chuyen huong sau thanh toan +/// EN: Customer IP address / VI: Dia chi IP khach hang +public record PaymentRequest( + Guid OrderId, + decimal Amount, + string Currency, + string Description, + string ReturnUrl, + string IpAddress +); + +/// +/// EN: Value object representing a payment gateway response. +/// VI: Value object dai dien cho ket qua tra ve tu cong thanh toan. +/// +/// EN: Whether the operation succeeded / VI: Thao tac co thanh cong khong +/// EN: Gateway transaction identifier / VI: Ma giao dich cong thanh toan +/// EN: URL to redirect customer for payment / VI: URL chuyen huong khach hang de thanh toan +/// EN: Error code if failed / VI: Ma loi neu that bai +/// EN: Error message if failed / VI: Thong bao loi neu that bai +/// EN: Raw gateway response data / VI: Du lieu phan hoi tho tu cong thanh toan +public record PaymentResult( + bool Success, + string? TransactionId = null, + string? PaymentUrl = null, + string? ErrorCode = null, + string? ErrorMessage = null, + IDictionary? GatewayResponse = null +); diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentStatus.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentStatus.cs new file mode 100644 index 00000000..63f64cd0 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PaymentAggregate/PaymentStatus.cs @@ -0,0 +1,42 @@ +namespace WalletService.Domain.AggregatesModel.PaymentAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Payment status enumeration using type-safe enum pattern. +/// VI: Enumeration trang thai thanh toan su dung pattern enum an toan kieu. +/// +public class PaymentStatus : Enumeration +{ + /// + /// EN: Payment has been created but not yet processed. + /// VI: Thanh toan da duoc tao nhung chua xu ly. + /// + public static PaymentStatus Pending = new(1, nameof(Pending)); + + /// + /// EN: Payment is being processed by the gateway. + /// VI: Thanh toan dang duoc xu ly boi cong thanh toan. + /// + public static PaymentStatus Processing = new(2, nameof(Processing)); + + /// + /// EN: Payment has been completed successfully. + /// VI: Thanh toan da hoan thanh thanh cong. + /// + public static PaymentStatus Completed = new(3, nameof(Completed)); + + /// + /// EN: Payment has failed. + /// VI: Thanh toan da that bai. + /// + public static PaymentStatus Failed = new(4, nameof(Failed)); + + /// + /// EN: Payment has been refunded. + /// VI: Thanh toan da duoc hoan tien. + /// + public static PaymentStatus Refunded = new(5, nameof(Refunded)); + + public PaymentStatus(int id, string name) : base(id, name) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/PaymentCompletedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/PaymentCompletedDomainEvent.cs new file mode 100644 index 00000000..b7c7db40 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/PaymentCompletedDomainEvent.cs @@ -0,0 +1,32 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when a payment is completed successfully. +/// VI: Domain event duoc phat ra khi thanh toan hoan thanh thanh cong. +/// +public class PaymentCompletedDomainEvent : INotification +{ + public Guid PaymentId { get; } + public Guid OrderId { get; } + public string TransactionId { get; } + public decimal Amount { get; } + public string Currency { get; } + public DateTime OccurredAt { get; } + + public PaymentCompletedDomainEvent( + Guid paymentId, + Guid orderId, + string transactionId, + decimal amount, + string currency) + { + PaymentId = paymentId; + OrderId = orderId; + TransactionId = transactionId; + Amount = amount; + Currency = currency; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/PaymentCreatedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/PaymentCreatedDomainEvent.cs new file mode 100644 index 00000000..a21ba504 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/PaymentCreatedDomainEvent.cs @@ -0,0 +1,32 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when a new payment is created. +/// VI: Domain event duoc phat ra khi mot thanh toan moi duoc tao. +/// +public class PaymentCreatedDomainEvent : INotification +{ + public Guid PaymentId { get; } + public Guid OrderId { get; } + public decimal Amount { get; } + public string Currency { get; } + public string GatewayName { get; } + public DateTime OccurredAt { get; } + + public PaymentCreatedDomainEvent( + Guid paymentId, + Guid orderId, + decimal amount, + string currency, + string gatewayName) + { + PaymentId = paymentId; + OrderId = orderId; + Amount = amount; + Currency = currency; + GatewayName = gatewayName; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/PaymentFailedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/PaymentFailedDomainEvent.cs new file mode 100644 index 00000000..3bd3933a --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/PaymentFailedDomainEvent.cs @@ -0,0 +1,29 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when a payment fails. +/// VI: Domain event duoc phat ra khi thanh toan that bai. +/// +public class PaymentFailedDomainEvent : INotification +{ + public Guid PaymentId { get; } + public Guid OrderId { get; } + public string? ErrorCode { get; } + public string? ErrorMessage { get; } + public DateTime OccurredAt { get; } + + public PaymentFailedDomainEvent( + Guid paymentId, + Guid orderId, + string? errorCode, + string? errorMessage) + { + PaymentId = paymentId; + OrderId = orderId; + ErrorCode = errorCode; + ErrorMessage = errorMessage; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs b/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs index b5d8f4a7..b8583154 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs @@ -3,8 +3,10 @@ namespace WalletService.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using WalletService.Domain.AggregatesModel.PaymentAggregate; using WalletService.Domain.AggregatesModel.PointAccountAggregate; using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Infrastructure.PaymentGateways; using WalletService.Infrastructure.Repositories; /// @@ -38,6 +40,13 @@ public static class DependencyInjection // VI: Đăng ký repositories services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + // EN: Register VN Pay options and gateway + // VI: Đăng ký VN Pay options va gateway + services.Configure(configuration.GetSection(VnPayOptions.SectionName)); + services.AddHttpClient(); + services.AddScoped(); return services; } diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs new file mode 100644 index 00000000..11e68ba9 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs @@ -0,0 +1,99 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.PaymentAggregate; + +/// +/// EN: EF Core configuration for Payment aggregate. +/// VI: Cau hinh EF Core cho aggregate Payment. +/// +public class PaymentEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("payments"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_orderId") + .HasColumnName("order_id") + .IsRequired(); + + builder.Property("_amount") + .HasColumnName("amount") + .HasPrecision(18, 2) + .IsRequired(); + + builder.Property("_currency") + .HasColumnName("currency") + .HasMaxLength(10) + .IsRequired(); + + builder.Property("_gatewayName") + .HasColumnName("gateway_name") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_transactionId") + .HasColumnName("transaction_id") + .HasMaxLength(255); + + builder.Property("_paymentUrl") + .HasColumnName("payment_url") + .HasMaxLength(2048); + + builder.Property("_statusId") + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_errorCode") + .HasColumnName("error_code") + .HasMaxLength(50); + + builder.Property("_errorMessage") + .HasColumnName("error_message") + .HasMaxLength(500); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_completedAt") + .HasColumnName("completed_at"); + + // EN: Ignore computed properties and domain events + // VI: Bo qua cac property tinh toan va domain events + builder.Ignore(p => p.OrderId); + builder.Ignore(p => p.Amount); + builder.Ignore(p => p.Currency); + builder.Ignore(p => p.GatewayName); + builder.Ignore(p => p.TransactionId); + builder.Ignore(p => p.PaymentUrl); + builder.Ignore(p => p.StatusId); + builder.Ignore(p => p.Status); + builder.Ignore(p => p.ErrorCode); + builder.Ignore(p => p.ErrorMessage); + builder.Ignore(p => p.CreatedAt); + builder.Ignore(p => p.CompletedAt); + builder.Ignore(p => p.DomainEvents); + + // EN: Indexes for common queries + // VI: Index cho cac truy van thuong dung + builder.HasIndex("_orderId") + .HasDatabaseName("ix_payments_order_id"); + + builder.HasIndex("_transactionId") + .HasDatabaseName("ix_payments_transaction_id"); + + builder.HasIndex("_statusId") + .HasDatabaseName("ix_payments_status_id"); + + builder.HasIndex("_gatewayName") + .HasDatabaseName("ix_payments_gateway_name"); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayGateway.cs b/services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayGateway.cs new file mode 100644 index 00000000..2a2a8986 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayGateway.cs @@ -0,0 +1,326 @@ +namespace WalletService.Infrastructure.PaymentGateways; + +using System.Globalization; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WalletService.Domain.AggregatesModel.PaymentAggregate; + +/// +/// EN: VN Pay payment gateway implementation (API v2.1.0). +/// VI: Trien khai cong thanh toan VN Pay (API v2.1.0). +/// +/// +/// EN: Supports payment creation, query, refund, and IPN callback validation. +/// Sandbox: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html +/// VI: Ho tro tao thanh toan, truy van, hoan tien, va xac thuc IPN callback. +/// Sandbox: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html +/// +public class VnPayGateway : IPaymentGateway +{ + private readonly VnPayOptions _options; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// EN: VN Pay API version. + /// VI: Phien ban API VN Pay. + /// + private const string VnPayVersion = "2.1.0"; + + /// + /// EN: VN Pay command for payment. + /// VI: Lenh VN Pay cho thanh toan. + /// + private const string VnPayCommandPay = "pay"; + + /// + /// EN: VN Pay command for query. + /// VI: Lenh VN Pay cho truy van. + /// + private const string VnPayCommandQuery = "querydr"; + + /// + /// EN: VN Pay command for refund. + /// VI: Lenh VN Pay cho hoan tien. + /// + private const string VnPayCommandRefund = "refund"; + + public string GatewayName => "VNPAY"; + + public VnPayGateway( + IOptions options, + HttpClient httpClient, + ILogger logger) + { + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public Task CreatePaymentAsync(PaymentRequest request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Creating VN Pay payment for order {OrderId}, amount {Amount} {Currency} / " + + "VI: Tao thanh toan VN Pay cho don hang {OrderId}, so tien {Amount} {Currency}", + request.OrderId, request.Amount, request.Currency); + + var vnpParams = new SortedDictionary + { + { "vnp_Version", VnPayVersion }, + { "vnp_Command", VnPayCommandPay }, + { "vnp_TmnCode", _options.TmnCode }, + { "vnp_Amount", ((long)(request.Amount * 100)).ToString() }, + { "vnp_CreateDate", DateTime.UtcNow.ToString("yyyyMMddHHmmss") }, + { "vnp_CurrCode", request.Currency.ToUpperInvariant() }, + { "vnp_IpAddr", request.IpAddress }, + { "vnp_Locale", "vn" }, + { "vnp_OrderInfo", request.Description }, + { "vnp_OrderType", "other" }, + { "vnp_ReturnUrl", request.ReturnUrl }, + { "vnp_TxnRef", request.OrderId.ToString("N") }, + { "vnp_ExpireDate", DateTime.UtcNow.AddMinutes(15).ToString("yyyyMMddHHmmss") } + }; + + // EN: Build query string and generate HMAC-SHA512 hash + // VI: Xay dung query string va tao hash HMAC-SHA512 + var queryString = BuildQueryString(vnpParams); + var secureHash = ComputeHmacSha512(_options.HashSecret, queryString); + + var paymentUrl = $"{_options.PaymentUrl}?{queryString}&vnp_SecureHash={secureHash}"; + + _logger.LogInformation( + "EN: VN Pay payment URL generated for order {OrderId} / " + + "VI: Da tao URL thanh toan VN Pay cho don hang {OrderId}", + request.OrderId); + + return Task.FromResult(new PaymentResult( + Success: true, + TransactionId: request.OrderId.ToString("N"), + PaymentUrl: paymentUrl + )); + } + + /// + public async Task QueryPaymentAsync(string transactionId, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Querying VN Pay transaction {TransactionId} / " + + "VI: Truy van giao dich VN Pay {TransactionId}", + transactionId); + + var requestDate = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var requestId = Guid.NewGuid().ToString("N"); + + var vnpParams = new SortedDictionary + { + { "vnp_RequestId", requestId }, + { "vnp_Version", VnPayVersion }, + { "vnp_Command", VnPayCommandQuery }, + { "vnp_TmnCode", _options.TmnCode }, + { "vnp_TxnRef", transactionId }, + { "vnp_OrderInfo", $"Query transaction {transactionId}" }, + { "vnp_TransactionDate", requestDate }, + { "vnp_CreateDate", requestDate }, + { "vnp_IpAddr", "127.0.0.1" } + }; + + // EN: Generate hash for query request + // VI: Tao hash cho yeu cau truy van + var hashData = $"{requestId}|{VnPayVersion}|{VnPayCommandQuery}|{_options.TmnCode}|{transactionId}|{requestDate}|{requestDate}|127.0.0.1|Query transaction {transactionId}"; + var secureHash = ComputeHmacSha512(_options.HashSecret, hashData); + vnpParams.Add("vnp_SecureHash", secureHash); + + try + { + var response = await _httpClient.PostAsJsonAsync( + _options.ApiUrl, + vnpParams, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "EN: VN Pay query failed with status {StatusCode} / " + + "VI: Truy van VN Pay that bai voi status {StatusCode}", + response.StatusCode); + + return new PaymentResult( + Success: false, + ErrorCode: "QUERY_FAILED", + ErrorMessage: $"VN Pay query returned HTTP {(int)response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var responseData = JsonSerializer.Deserialize>(content); + + var responseCode = responseData?.GetValueOrDefault("vnp_ResponseCode") ?? "99"; + var isSuccess = responseCode == "00"; + + return new PaymentResult( + Success: isSuccess, + TransactionId: responseData?.GetValueOrDefault("vnp_TransactionNo"), + ErrorCode: isSuccess ? null : responseCode, + ErrorMessage: isSuccess ? null : $"VN Pay response code: {responseCode}", + GatewayResponse: responseData?.ToDictionary(k => k.Key, v => v.Value)); + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Error querying VN Pay transaction {TransactionId} / " + + "VI: Loi truy van giao dich VN Pay {TransactionId}", + transactionId); + + return new PaymentResult( + Success: false, + ErrorCode: "QUERY_ERROR", + ErrorMessage: ex.Message); + } + } + + /// + public async Task RefundAsync(string transactionId, decimal amount, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Requesting VN Pay refund for transaction {TransactionId}, amount {Amount} / " + + "VI: Yeu cau hoan tien VN Pay cho giao dich {TransactionId}, so tien {Amount}", + transactionId, amount); + + var requestDate = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var requestId = Guid.NewGuid().ToString("N"); + + var vnpParams = new SortedDictionary + { + { "vnp_RequestId", requestId }, + { "vnp_Version", VnPayVersion }, + { "vnp_Command", VnPayCommandRefund }, + { "vnp_TmnCode", _options.TmnCode }, + { "vnp_TransactionType", "02" }, // Full refund + { "vnp_TxnRef", transactionId }, + { "vnp_Amount", ((long)(amount * 100)).ToString() }, + { "vnp_OrderInfo", $"Refund transaction {transactionId}" }, + { "vnp_TransactionDate", requestDate }, + { "vnp_CreateDate", requestDate }, + { "vnp_IpAddr", "127.0.0.1" } + }; + + // EN: Generate hash for refund request + // VI: Tao hash cho yeu cau hoan tien + var hashData = $"{requestId}|{VnPayVersion}|{VnPayCommandRefund}|{_options.TmnCode}|02|{transactionId}|{(long)(amount * 100)}|0|{requestDate}|{requestDate}|127.0.0.1|Refund transaction {transactionId}"; + var secureHash = ComputeHmacSha512(_options.HashSecret, hashData); + vnpParams.Add("vnp_SecureHash", secureHash); + + try + { + var response = await _httpClient.PostAsJsonAsync( + _options.ApiUrl, + vnpParams, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return new PaymentResult( + Success: false, + ErrorCode: "REFUND_FAILED", + ErrorMessage: $"VN Pay refund returned HTTP {(int)response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var responseData = JsonSerializer.Deserialize>(content); + + var responseCode = responseData?.GetValueOrDefault("vnp_ResponseCode") ?? "99"; + var isSuccess = responseCode == "00"; + + return new PaymentResult( + Success: isSuccess, + TransactionId: responseData?.GetValueOrDefault("vnp_TransactionNo"), + ErrorCode: isSuccess ? null : responseCode, + ErrorMessage: isSuccess ? null : $"VN Pay refund response code: {responseCode}", + GatewayResponse: responseData?.ToDictionary(k => k.Key, v => v.Value)); + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Error requesting VN Pay refund for transaction {TransactionId} / " + + "VI: Loi yeu cau hoan tien VN Pay cho giao dich {TransactionId}", + transactionId); + + return new PaymentResult( + Success: false, + ErrorCode: "REFUND_ERROR", + ErrorMessage: ex.Message); + } + } + + /// + public bool ValidateCallback(IDictionary parameters, string secureHash) + { + _logger.LogInformation( + "EN: Validating VN Pay IPN callback / " + + "VI: Xac thuc VN Pay IPN callback"); + + // EN: Remove the secure hash from parameters before verification + // VI: Loai bo secure hash khoi cac tham so truoc khi xac thuc + var vnpParams = new SortedDictionary( + parameters + .Where(p => p.Key.StartsWith("vnp_") && + p.Key != "vnp_SecureHash" && + p.Key != "vnp_SecureHashType") + .ToDictionary(p => p.Key, p => p.Value)); + + var queryString = BuildQueryString(vnpParams); + var computedHash = ComputeHmacSha512(_options.HashSecret, queryString); + + var isValid = string.Equals(computedHash, secureHash, StringComparison.OrdinalIgnoreCase); + + if (!isValid) + { + _logger.LogWarning( + "EN: VN Pay IPN callback hash validation failed / " + + "VI: Xac thuc hash IPN callback VN Pay that bai"); + } + + return isValid; + } + + #region Private Helpers + + /// + /// EN: Build URL-encoded query string from sorted parameters. + /// VI: Xay dung query string duoc URL-encode tu cac tham so da sap xep. + /// + private static string BuildQueryString(SortedDictionary parameters) + { + var sb = new StringBuilder(); + foreach (var kvp in parameters) + { + if (sb.Length > 0) + sb.Append('&'); + sb.Append(Uri.EscapeDataString(kvp.Key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(kvp.Value)); + } + return sb.ToString(); + } + + /// + /// EN: Compute HMAC-SHA512 hash. + /// VI: Tinh toan hash HMAC-SHA512. + /// + private static string ComputeHmacSha512(string key, string data) + { + var keyBytes = Encoding.UTF8.GetBytes(key); + var dataBytes = Encoding.UTF8.GetBytes(data); + + using var hmac = new HMACSHA512(keyBytes); + var hashBytes = hmac.ComputeHash(dataBytes); + + return Convert.ToHexStringLower(hashBytes); + } + + #endregion +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayOptions.cs b/services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayOptions.cs new file mode 100644 index 00000000..1c6493fc --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/PaymentGateways/VnPayOptions.cs @@ -0,0 +1,44 @@ +namespace WalletService.Infrastructure.PaymentGateways; + +/// +/// EN: Configuration options for VN Pay payment gateway. +/// VI: Cac tuy chon cau hinh cho cong thanh toan VN Pay. +/// +public class VnPayOptions +{ + /// + /// EN: Configuration section name. + /// VI: Ten section cau hinh. + /// + public const string SectionName = "VnPay"; + + /// + /// EN: Merchant terminal code assigned by VN Pay. + /// VI: Ma terminal merchant duoc VN Pay cap. + /// + public string TmnCode { get; set; } = string.Empty; + + /// + /// EN: Secret key for HMAC-SHA512 hash generation. + /// VI: Khoa bi mat de tao hash HMAC-SHA512. + /// + public string HashSecret { get; set; } = string.Empty; + + /// + /// EN: VN Pay payment URL (sandbox or production). + /// VI: URL thanh toan VN Pay (sandbox hoac production). + /// + public string PaymentUrl { get; set; } = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html"; + + /// + /// EN: Return URL after payment completion. + /// VI: URL tra ve sau khi hoan thanh thanh toan. + /// + public string ReturnUrl { get; set; } = string.Empty; + + /// + /// EN: VN Pay API URL for querying transaction status. + /// VI: URL API VN Pay de truy van trang thai giao dich. + /// + public string ApiUrl { get; set; } = "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction"; +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PaymentRepository.cs b/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PaymentRepository.cs new file mode 100644 index 00000000..e536e3f1 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PaymentRepository.cs @@ -0,0 +1,64 @@ +namespace WalletService.Infrastructure.Repositories; + +using Microsoft.EntityFrameworkCore; +using WalletService.Domain.AggregatesModel.PaymentAggregate; +using WalletService.Domain.SeedWork; + +/// +/// EN: Repository implementation for Payment aggregate. +/// VI: Repository implementation cho aggregate Payment. +/// +public class PaymentRepository : IPaymentRepository +{ + private readonly WalletServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public PaymentRepository(WalletServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid paymentId) + { + return await _context.Payments + .FirstOrDefaultAsync(p => p.Id == paymentId); + } + + /// + public async Task GetByOrderIdAsync(Guid orderId) + { + return await _context.Payments + .OrderByDescending(p => p.CreatedAt) + .FirstOrDefaultAsync(p => p.OrderId == orderId); + } + + /// + public async Task GetByTransactionIdAsync(string transactionId) + { + return await _context.Payments + .FirstOrDefaultAsync(p => p.TransactionId == transactionId); + } + + /// + public async Task> GetPaymentsByOrderIdAsync(Guid orderId) + { + return await _context.Payments + .Where(p => p.OrderId == orderId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + /// + public Payment Add(Payment payment) + { + return _context.Payments.Add(payment).Entity; + } + + /// + public void Update(Payment payment) + { + _context.Entry(payment).State = EntityState.Modified; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs index 9611c55a..fd20d05c 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs @@ -3,6 +3,7 @@ namespace WalletService.Infrastructure; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using WalletService.Domain.AggregatesModel.PaymentAggregate; using WalletService.Domain.AggregatesModel.PointAccountAggregate; using WalletService.Domain.AggregatesModel.WalletAggregate; using WalletService.Domain.SeedWork; @@ -20,6 +21,7 @@ public class WalletServiceContext : DbContext, IUnitOfWork public DbSet WalletItems { get; set; } = null!; public DbSet WalletTransactions { get; set; } = null!; public DbSet WalletHolds { get; set; } = null!; + public DbSet Payments { get; set; } = null!; public DbSet PointAccounts { get; set; } = null!; public DbSet PointTransactions { get; set; } = null!;