feat: implement Phase 1 payment gateway, real-time SignalR, kitchen-inventory deduction, and order payment flow
- 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 <noreply@anthropic.com>
This commit is contained in:
30
ROADMAP.md
30
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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -775,12 +775,50 @@ public class PosDataService
|
||||
|
||||
// ═══ PAY ORDER ═══
|
||||
|
||||
public async Task<bool> PayOrderAsync(Guid orderId, Guid shopId, string? paymentMethod = null)
|
||||
/// <summary>
|
||||
/// EN: Payment result from order-service via BFF.
|
||||
/// VI: Kết quả thanh toán từ order-service qua BFF.
|
||||
/// </summary>
|
||||
public record PayOrderResponse(bool Success, string? Status, string? PaymentUrl, decimal? ChangeAmount, string? TransactionId, string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<PayOrderResponse?> 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<PayOrderResponse>(data.GetRawText(), _jsonOptions);
|
||||
}
|
||||
return System.Text.Json.JsonSerializer.Deserialize<PayOrderResponse>(json, _jsonOptions);
|
||||
}
|
||||
catch { return new PayOrderResponse(true, null, null, null, null, null); }
|
||||
}
|
||||
return new PayOrderResponse(false, null, null, null, null, "Payment failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<bool> PayOrderAsync(Guid orderId, Guid shopId, string? paymentMethod = null)
|
||||
{
|
||||
var result = await PayOrderWithDetailsAsync(orderId, shopId, paymentMethod ?? "cash");
|
||||
return result?.Success ?? false;
|
||||
}
|
||||
|
||||
// ═══ ACTIVE TABLE ORDERS ═══
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("orders/{orderId:guid}/pay")]
|
||||
public Task<IActionResult> PayOrder(Guid orderId, [FromQuery] Guid? shopId = null)
|
||||
public Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,5 +47,8 @@ public record UpdateTicketStatusRequest(string Status);
|
||||
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? 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);
|
||||
|
||||
@@ -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"
|
||||
- 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"
|
||||
@@ -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<Guid>;
|
||||
|
||||
@@ -20,7 +20,9 @@ public class CreateKitchenTicketCommandHandler : IRequestHandler<CreateKitchenTi
|
||||
var ticket = new KitchenTicket(
|
||||
request.SessionId,
|
||||
request.OrderItemId,
|
||||
request.ProductId ?? request.OrderItemId,
|
||||
request.ItemName,
|
||||
request.Quantity,
|
||||
request.Station,
|
||||
request.Priority);
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// EN: Domain event handler for KitchenTicketServedDomainEvent.
|
||||
// Looks up the recipe for the served product and calls inventory-service to deduct ingredients.
|
||||
// VI: Domain event handler cho KitchenTicketServedDomainEvent.
|
||||
// Tra cuu cong thuc cho san pham da phuc vu va goi inventory-service de tru nguyen lieu.
|
||||
|
||||
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
||||
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
|
||||
using FnbEngine.Domain.Events;
|
||||
using FnbEngine.Infrastructure.ExternalServices;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FnbEngine.API.Application.IntegrationEvents.EventHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handles KitchenTicketServedDomainEvent by looking up recipe and deducting inventory.
|
||||
/// VI: Xu ly KitchenTicketServedDomainEvent bang cach tra cuu cong thuc va tru kho.
|
||||
/// </summary>
|
||||
public class KitchenTicketServedDomainEventHandler : INotificationHandler<KitchenTicketServedDomainEvent>
|
||||
{
|
||||
private readonly IRecipeRepository _recipeRepository;
|
||||
private readonly ISessionRepository _sessionRepository;
|
||||
private readonly IInventoryServiceClient _inventoryClient;
|
||||
private readonly ILogger<KitchenTicketServedDomainEventHandler> _logger;
|
||||
|
||||
public KitchenTicketServedDomainEventHandler(
|
||||
IRecipeRepository recipeRepository,
|
||||
ISessionRepository sessionRepository,
|
||||
IInventoryServiceClient inventoryClient,
|
||||
ILogger<KitchenTicketServedDomainEventHandler> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record KitchenTicketServedIntegrationEvent(
|
||||
Guid TicketId,
|
||||
Guid OrderItemId,
|
||||
Guid ProductId,
|
||||
Guid ShopId,
|
||||
string ProductName,
|
||||
int Quantity,
|
||||
DateTime ServedAt) : INotification;
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product ID from Catalog Service (for recipe lookup).
|
||||
/// VI: ID san pham tu Catalog Service (de tra cuu cong thuc).
|
||||
/// </summary>
|
||||
public Guid ProductId => _productId;
|
||||
public string ItemName => _itemName;
|
||||
public string? Station => _station;
|
||||
public int Priority => _priority;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Quantity of items ordered.
|
||||
/// VI: So luong item da dat.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a kitchen ticket with product ID and quantity.
|
||||
/// VI: Tao phieu bep voi product ID va so luong.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record KitchenTicketServedDomainEvent(KitchenTicket Ticket) : INotification;
|
||||
@@ -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<IRequestManager, RequestManager>();
|
||||
|
||||
// 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<IInventoryServiceClient, InventoryServiceClient>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ public class KitchenTicketEntityTypeConfiguration : IEntityTypeConfiguration<Kit
|
||||
.HasColumnName("order_item_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(kt => kt.ProductId)
|
||||
.HasColumnName("product_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(kt => kt.ItemName)
|
||||
.HasColumnName("item_name")
|
||||
.HasMaxLength(200)
|
||||
@@ -44,6 +48,11 @@ public class KitchenTicketEntityTypeConfiguration : IEntityTypeConfiguration<Kit
|
||||
.HasColumnName("priority")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(kt => kt.Quantity)
|
||||
.HasColumnName("quantity")
|
||||
.IsRequired()
|
||||
.HasDefaultValue(1);
|
||||
|
||||
builder.Property(kt => kt.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(50)
|
||||
@@ -56,6 +65,8 @@ public class KitchenTicketEntityTypeConfiguration : IEntityTypeConfiguration<Kit
|
||||
builder.Property(kt => 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)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// EN: Interface for inventory service HTTP client.
|
||||
// VI: Interface cho HTTP client cua inventory service.
|
||||
|
||||
namespace FnbEngine.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Client interface for calling inventory-service API.
|
||||
/// VI: Interface client de goi API cua inventory-service.
|
||||
/// </summary>
|
||||
public interface IInventoryServiceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Deduct inventory items (bulk deduction via inventory-service API).
|
||||
/// VI: Tru kho (tru hang loat qua API inventory-service).
|
||||
/// </summary>
|
||||
Task<bool> DeductInventoryAsync(DeductInventoryRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request DTO for bulk inventory deduction.
|
||||
/// VI: DTO request cho tru kho hang loat.
|
||||
/// </summary>
|
||||
public record DeductInventoryRequest(
|
||||
Guid ShopId,
|
||||
Guid ReferenceId,
|
||||
string ReferenceType,
|
||||
string Reason,
|
||||
List<DeductionItemDto> Items);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Individual item to deduct from inventory.
|
||||
/// VI: Item rieng le de tru tu kho.
|
||||
/// </summary>
|
||||
public record DeductionItemDto(
|
||||
Guid InventoryItemId,
|
||||
int Amount,
|
||||
string Unit,
|
||||
string IngredientName);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: HTTP client for inventory-service with Polly retry policies.
|
||||
/// VI: HTTP client cho inventory-service voi Polly retry policies.
|
||||
/// </summary>
|
||||
public class InventoryServiceClient : IInventoryServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<InventoryServiceClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public InventoryServiceClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<InventoryServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to deduct multiple inventory items in a single operation.
|
||||
/// VI: Command để trừ nhiều inventory items trong một thao tác.
|
||||
/// </summary>
|
||||
public record DeductInventoryCommand(
|
||||
Guid ShopId,
|
||||
Guid ReferenceId,
|
||||
string ReferenceType,
|
||||
string Reason,
|
||||
List<DeductionItem> Items) : IRequest<DeductInventoryResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Individual item to deduct from inventory.
|
||||
/// VI: Item riêng lẻ để trừ từ kho.
|
||||
/// </summary>
|
||||
public record DeductionItem(
|
||||
Guid InventoryItemId,
|
||||
int Amount,
|
||||
string Unit,
|
||||
string IngredientName);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of bulk inventory deduction.
|
||||
/// VI: Kết quả trừ kho hàng loạt.
|
||||
/// </summary>
|
||||
public record DeductInventoryResult(
|
||||
bool Success,
|
||||
int ItemsDeducted,
|
||||
int ItemsSkipped,
|
||||
List<DeductionResultItem> Items);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for each individual deduction item.
|
||||
/// VI: Kết quả cho mỗi item trừ kho riêng lẻ.
|
||||
/// </summary>
|
||||
public record DeductionResultItem(
|
||||
Guid InventoryItemId,
|
||||
string IngredientName,
|
||||
bool Deducted,
|
||||
string? Error);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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ụ.
|
||||
/// </summary>
|
||||
public class DeductInventoryCommandHandler : IRequestHandler<DeductInventoryCommand, DeductInventoryResult>
|
||||
{
|
||||
private readonly IInventoryRepository _repository;
|
||||
private readonly IRequestManager _requestManager;
|
||||
private readonly ILogger<DeductInventoryCommandHandler> _logger;
|
||||
|
||||
public DeductInventoryCommandHandler(
|
||||
IInventoryRepository repository,
|
||||
IRequestManager requestManager,
|
||||
ILogger<DeductInventoryCommandHandler> 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<DeductInventoryResult> 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<DeductInventoryCommand>(request.ReferenceId);
|
||||
|
||||
var resultItems = new List<DeductionResultItem>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,27 @@ public record StocktakeRequest(Guid ShopId, List<StocktakeItemRequest> Items);
|
||||
|
||||
public record StocktakeItemRequest(Guid InventoryItemId, int CountedQuantity);
|
||||
|
||||
/// <summary>
|
||||
/// 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ụ).
|
||||
/// </summary>
|
||||
public record DeductInventoryRequest(
|
||||
Guid ShopId,
|
||||
Guid ReferenceId,
|
||||
string ReferenceType,
|
||||
string? Reason,
|
||||
List<DeductionItemRequest> Items);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record DeductionItemRequest(
|
||||
Guid InventoryItemId,
|
||||
int Amount,
|
||||
string Unit,
|
||||
string IngredientName);
|
||||
|
||||
public class ApiResponse<T>
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validates DeductInventoryCommand before processing.
|
||||
/// VI: Validate DeductInventoryCommand trước khi xử lý.
|
||||
/// </summary>
|
||||
public class DeductInventoryCommandValidator : AbstractValidator<DeductInventoryCommand>
|
||||
{
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<PagedResult<InventoryTransactionDto>>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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ụ).
|
||||
/// </summary>
|
||||
[HttpPost("deduct")]
|
||||
[SwaggerOperation(Summary = "Bulk deduct inventory items (cross-service: kitchen ticket served)")]
|
||||
[SwaggerResponse(200, "Deduction processed")]
|
||||
[SwaggerResponse(400, "Invalid request")]
|
||||
public async Task<ActionResult<ApiResponse<DeductInventoryResult>>> 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<DeductInventoryResult>.Ok(result));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "EN: Error processing inventory deduction / VI: Lỗi xử lý trừ kho");
|
||||
return BadRequest(ApiResponse<DeductInventoryResult>.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get low stock items.
|
||||
/// VI: Lấy các items stock thấp.
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
|
||||
public DbSet<ClientRequest> ClientRequests => Set<ClientRequest>();
|
||||
|
||||
/// <summary>
|
||||
/// 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<ClientRequest>(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.
|
||||
|
||||
@@ -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<CancelOrderCommand, CancelOrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IPosNotificationService _posNotificationService;
|
||||
private readonly ILogger<CancelOrderCommandHandler> _logger;
|
||||
|
||||
public CancelOrderCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IPosNotificationService posNotificationService,
|
||||
ILogger<CancelOrderCommandHandler> 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<CancelOrderCommand, Can
|
||||
order.Id,
|
||||
request.Reason);
|
||||
|
||||
// EN: Send real-time cancellation notification / VI: Gửi thông báo hủy order real-time
|
||||
try
|
||||
{
|
||||
await _posNotificationService.NotifyOrderStatusChangedAsync(
|
||||
order.ShopId, order.Id, "Active", order.Status.Name, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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 CancelOrderResult(true, order.Status.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// VI: Handler cho CompleteOrderCommand.
|
||||
|
||||
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 CompleteOrderCommandHandler : IRequestHandler<CompleteOrderCommand, CompleteOrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IPosNotificationService _posNotificationService;
|
||||
private readonly ILogger<CompleteOrderCommandHandler> _logger;
|
||||
|
||||
public CompleteOrderCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IPosNotificationService posNotificationService,
|
||||
ILogger<CompleteOrderCommandHandler> 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<CompleteOrderCommand,
|
||||
"EN: Order completed successfully / VI: Hoàn thành order thành công: {OrderId}",
|
||||
order.Id);
|
||||
|
||||
// EN: Send real-time status change notification / VI: Gửi thông báo thay đổi trạng thái real-time
|
||||
try
|
||||
{
|
||||
await _posNotificationService.NotifyOrderStatusChangedAsync(
|
||||
order.ShopId, order.Id, "Processing", order.Status.Name, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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 CompleteOrderResult(true, order.Status.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// EN: Command to complete order payment after gateway callback confirmation.
|
||||
// VI: Command hoàn tất thanh toán order sau khi nhận callback xác nhận từ cổng thanh toán.
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace OrderService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CompleteOrderPaymentResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of completing order payment.
|
||||
/// VI: Kết quả hoàn tất thanh toán order.
|
||||
/// </summary>
|
||||
public record CompleteOrderPaymentResult(
|
||||
bool Success,
|
||||
string Status,
|
||||
string? ErrorMessage
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderPaymentCommand, CompleteOrderPaymentResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly ILogger<CompleteOrderPaymentCommandHandler> _logger;
|
||||
|
||||
public CompleteOrderPaymentCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ILogger<CompleteOrderPaymentCommandHandler> logger)
|
||||
{
|
||||
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CompleteOrderPaymentResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<CreateOrderCommand, Cre
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IEnumerable<ILineItemStrategy> _strategies;
|
||||
private readonly IPosNotificationService _posNotificationService;
|
||||
private readonly ILogger<CreateOrderCommandHandler> _logger;
|
||||
|
||||
public CreateOrderCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IEnumerable<ILineItemStrategy> strategies,
|
||||
IPosNotificationService posNotificationService,
|
||||
ILogger<CreateOrderCommandHandler> 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<CreateOrderCommand, Cre
|
||||
"EN: Order created successfully / VI: Tạo order thành công: {OrderId}",
|
||||
order.Id);
|
||||
|
||||
// EN: Send real-time notification to POS/KDS clients
|
||||
// VI: Gửi thông báo real-time đến POS/KDS clients
|
||||
try
|
||||
{
|
||||
var orderNotification = new OrderNotificationDto(
|
||||
OrderId: order.Id,
|
||||
ShopId: order.ShopId,
|
||||
Status: order.Status.Name,
|
||||
Items: order.Items.Select(i => 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PayOrderResult>;
|
||||
|
||||
/// <summary>
|
||||
@@ -20,5 +24,9 @@ public record PayOrderCommand(
|
||||
/// </summary>
|
||||
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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PayOrderCommandHandler : IRequestHandler<PayOrderCommand, PayOrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IWalletServiceClient _walletServiceClient;
|
||||
private readonly IPosNotificationService _posNotificationService;
|
||||
private readonly ILogger<PayOrderCommandHandler> _logger;
|
||||
|
||||
public PayOrderCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IWalletServiceClient walletServiceClient,
|
||||
IPosNotificationService posNotificationService,
|
||||
ILogger<PayOrderCommandHandler> 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<PayOrderCommand, PayOrderR
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"EN: Processing payment for order {OrderId} / VI: Xử lý thanh toán cho order {OrderId}",
|
||||
request.OrderId);
|
||||
"EN: Processing {PaymentMethod} payment for order {OrderId} / VI: Xử lý thanh toán {PaymentMethod} cho order {OrderId}",
|
||||
request.PaymentMethod, request.OrderId);
|
||||
|
||||
// EN: Load order
|
||||
// VI: Load order
|
||||
// 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 shop ownership
|
||||
// VI: Xác minh quyền sở hữu shop
|
||||
// EN: Verify shop ownership / VI: Xác minh quyền sở hữu shop
|
||||
if (order.ShopId != request.ShopId)
|
||||
{
|
||||
throw new DomainException("Order does not belong to this shop");
|
||||
}
|
||||
|
||||
// EN: Mark order as paid (will validate status internally)
|
||||
// VI: Đánh dấu order là đã thanh toán (sẽ validate status bên trong)
|
||||
order.MarkAsPaid();
|
||||
var method = request.PaymentMethod.ToLowerInvariant();
|
||||
|
||||
// EN: Mark order as processing (strategies already executed at order creation)
|
||||
// VI: Đánh dấu order là đang xử lý (strategies đã được thực thi khi tạo order)
|
||||
return method switch
|
||||
{
|
||||
"cash" => 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),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<PayOrderResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private async Task<PayOrderResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<PayOrderResult> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OrderItemDto> Items
|
||||
|
||||
@@ -30,12 +30,16 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string orderSql = @"
|
||||
SELECT
|
||||
SELECT
|
||||
o.id AS Id,
|
||||
o.shop_id AS ShopId,
|
||||
o.customer_id AS CustomerId,
|
||||
os.name AS Status,
|
||||
o.total_amount AS TotalAmount,
|
||||
o.payment_method AS PaymentMethod,
|
||||
o.transaction_id AS TransactionId,
|
||||
o.amount_tendered AS AmountTendered,
|
||||
o.change_amount AS ChangeAmount,
|
||||
o.created_at AS CreatedAt,
|
||||
o.updated_at AS UpdatedAt
|
||||
FROM orders o
|
||||
@@ -82,6 +86,10 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
|
||||
order.CustomerId,
|
||||
order.Status,
|
||||
order.TotalAmount,
|
||||
order.PaymentMethod,
|
||||
order.TransactionId,
|
||||
order.AmountTendered,
|
||||
order.ChangeAmount,
|
||||
order.CreatedAt,
|
||||
order.UpdatedAt,
|
||||
items.ToList()
|
||||
@@ -96,6 +104,10 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
|
||||
Guid? CustomerId,
|
||||
string Status,
|
||||
decimal TotalAmount,
|
||||
string? PaymentMethod,
|
||||
string? TransactionId,
|
||||
decimal? AmountTendered,
|
||||
decimal? ChangeAmount,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// EN: Validator for PayOrderCommand.
|
||||
// VI: Validator cho PayOrderCommand.
|
||||
// EN: Validator for PayOrderCommand with payment method-specific rules.
|
||||
// VI: Validator cho PayOrderCommand với quy tắc theo phương thức thanh toán.
|
||||
|
||||
using FluentValidation;
|
||||
|
||||
namespace OrderService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PayOrderCommandValidator : AbstractValidator<Commands.PayOrderCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Supported payment methods.
|
||||
/// VI: Các phương thức thanh toán được hỗ trợ.
|
||||
/// </summary>
|
||||
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<Commands.PayOrderComma
|
||||
RuleFor(x => 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ public class OrdersController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/pay")]
|
||||
[ProducesResponseType(typeof(PayOrderResult), StatusCodes.Status200OK)]
|
||||
@@ -112,16 +112,62 @@ public class OrdersController : ControllerBase
|
||||
public async Task<ActionResult<PayOrderResult>> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/payment-callback")]
|
||||
[ProducesResponseType(typeof(CompleteOrderPaymentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CompleteOrderPaymentResult>> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -231,3 +277,23 @@ public class OrdersController : ControllerBase
|
||||
/// VI: Yêu cầu hủy order.
|
||||
/// </summary>
|
||||
public record CancelOrderRequest(string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment callback request from wallet-service.
|
||||
/// VI: Yêu cầu callback thanh toán từ wallet-service.
|
||||
/// </summary>
|
||||
public record PaymentCallbackRequest(
|
||||
string GatewayTransactionId,
|
||||
bool IsSuccess,
|
||||
string? GatewayResponseCode
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom user ID provider that extracts user ID from JWT claims.
|
||||
/// VI: Custom user ID provider lấy user ID từ JWT claims.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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ị.
|
||||
/// </remarks>
|
||||
public class ClaimsUserIdProvider : IUserIdProvider
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for order notification via SignalR.
|
||||
/// VI: DTO cho thông báo order qua SignalR.
|
||||
/// </summary>
|
||||
public record OrderNotificationDto(
|
||||
Guid OrderId,
|
||||
Guid ShopId,
|
||||
string Status,
|
||||
IReadOnlyCollection<OrderItemNotificationDto> Items,
|
||||
decimal TotalAmount,
|
||||
Guid? CustomerId,
|
||||
Guid? TableId,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for order item in notification.
|
||||
/// VI: DTO cho order item trong thông báo.
|
||||
/// </summary>
|
||||
public record OrderItemNotificationDto(
|
||||
Guid ItemId,
|
||||
Guid ProductId,
|
||||
string ProductName,
|
||||
string ProductType,
|
||||
int Quantity,
|
||||
decimal UnitPrice,
|
||||
decimal TotalPrice,
|
||||
string Status
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for kitchen ticket notification via SignalR.
|
||||
/// VI: DTO cho thông báo phiếu bếp qua SignalR.
|
||||
/// </summary>
|
||||
public record KitchenTicketNotificationDto(
|
||||
Guid TicketId,
|
||||
Guid OrderId,
|
||||
IReadOnlyCollection<KitchenTicketItemDto> Items,
|
||||
int Priority,
|
||||
string Status,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for kitchen ticket item.
|
||||
/// VI: DTO cho item trong phiếu bếp.
|
||||
/// </summary>
|
||||
public record KitchenTicketItemDto(
|
||||
Guid ProductId,
|
||||
string ProductName,
|
||||
int Quantity,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for payment notification via SignalR.
|
||||
/// VI: DTO cho thông báo thanh toán qua SignalR.
|
||||
/// </summary>
|
||||
public record PaymentNotificationDto(
|
||||
Guid OrderId,
|
||||
decimal Amount,
|
||||
string Method,
|
||||
string Status
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record TableStatusNotificationDto(
|
||||
Guid TableId,
|
||||
string TableName,
|
||||
string Status
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record OrderStatusChangedDto(
|
||||
Guid OrderId,
|
||||
string OldStatus,
|
||||
string NewStatus,
|
||||
DateTime ChangedAt
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
// EN: Strongly-typed client interface for PosHub.
|
||||
// VI: Interface client strongly-typed cho PosHub.
|
||||
|
||||
namespace OrderService.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Strongly-typed SignalR client interface for POS/KDS real-time updates.
|
||||
/// VI: Interface SignalR client strongly-typed cho POS/KDS real-time updates.
|
||||
/// </summary>
|
||||
public interface IPosHubClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Notify clients that a new order was created.
|
||||
/// VI: Thông báo clients rằng order mới đã được tạo.
|
||||
/// </summary>
|
||||
Task OrderCreated(OrderNotificationDto order);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notify clients that an order was updated.
|
||||
/// VI: Thông báo clients rằng order đã được cập nhật.
|
||||
/// </summary>
|
||||
Task OrderUpdated(OrderNotificationDto order);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notify clients that an order status changed.
|
||||
/// VI: Thông báo clients rằng trạng thái order đã thay đổi.
|
||||
/// </summary>
|
||||
Task OrderStatusChanged(OrderStatusChangedDto statusChange);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task KitchenTicketCreated(KitchenTicketNotificationDto ticket);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task KitchenTicketUpdated(KitchenTicketNotificationDto ticket);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notify clients that payment was completed.
|
||||
/// VI: Thông báo clients rằng thanh toán đã hoàn thành.
|
||||
/// </summary>
|
||||
Task PaymentCompleted(PaymentNotificationDto payment);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notify clients that a table status changed.
|
||||
/// VI: Thông báo clients rằng trạng thái bàn đã thay đổi.
|
||||
/// </summary>
|
||||
Task TableStatusChanged(TableStatusNotificationDto tableStatus);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IPosNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task NotifyOrderCreatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notify shop group that an order was updated.
|
||||
/// VI: Thông báo group shop rằng order đã được cập nhật.
|
||||
/// </summary>
|
||||
Task NotifyOrderUpdatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task NotifyOrderStatusChangedAsync(Guid shopId, Guid orderId, string oldStatus, string newStatus, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task NotifyKitchenTicketCreatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task NotifyKitchenTicketUpdatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notify shop group that payment was completed.
|
||||
/// VI: Thông báo group shop rằng thanh toán đã hoàn thành.
|
||||
/// </summary>
|
||||
Task NotifyPaymentCompletedAsync(Guid shopId, PaymentNotificationDto payment, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task NotifyTableStatusChangedAsync(Guid shopId, TableStatusNotificationDto tableStatus, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Thread-safe connection manager for POS/KDS hub connections.
|
||||
/// VI: Connection manager thread-safe cho POS/KDS hub connections.
|
||||
/// </summary>
|
||||
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<string, Guid> _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<Guid, ConcurrentDictionary<string, byte>> _shopConnections = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Register a connection for a shop.
|
||||
/// VI: Đăng ký kết nối cho shop.
|
||||
/// </summary>
|
||||
public void AddConnection(string connectionId, Guid shopId)
|
||||
{
|
||||
_connectionToShop[connectionId] = shopId;
|
||||
|
||||
var connections = _shopConnections.GetOrAdd(shopId, _ => new ConcurrentDictionary<string, byte>());
|
||||
connections[connectionId] = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all connection IDs for a specific shop.
|
||||
/// VI: Lấy tất cả connection IDs cho shop cụ thể.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetShopConnections(Guid shopId)
|
||||
{
|
||||
if (_shopConnections.TryGetValue(shopId, out var connections))
|
||||
{
|
||||
return connections.Keys.ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get the number of active connections for a shop.
|
||||
/// VI: Lấy số lượng kết nối active cho shop.
|
||||
/// </summary>
|
||||
public int GetShopConnectionCount(Guid shopId)
|
||||
{
|
||||
if (_shopConnections.TryGetValue(shopId, out var connections))
|
||||
{
|
||||
return connections.Count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get total number of active connections.
|
||||
/// VI: Lấy tổng số kết nối active.
|
||||
/// </summary>
|
||||
public int TotalConnections => _connectionToShop.Count;
|
||||
}
|
||||
203
services/order-service-net/src/OrderService.API/Hubs/PosHub.cs
Normal file
203
services/order-service-net/src/OrderService.API/Hubs/PosHub.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Real-time SignalR hub for POS and KDS updates.
|
||||
/// VI: SignalR hub thời gian thực cho POS và KDS.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// </remarks>
|
||||
[Authorize]
|
||||
public class PosHub : Hub<IPosHubClient>
|
||||
{
|
||||
private readonly PosConnectionManager _connectionManager;
|
||||
private readonly ILogger<PosHub> _logger;
|
||||
|
||||
public PosHub(
|
||||
PosConnectionManager connectionManager,
|
||||
ILogger<PosHub> logger)
|
||||
{
|
||||
_connectionManager = connectionManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
#region Connection Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// EN: Called when a new connection is established.
|
||||
/// VI: Được gọi khi kết nối mới được thiết lập.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Called when a connection is terminated.
|
||||
/// VI: Được gọi khi kết nối bị ngắt.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 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 đó.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Join a KDS group to receive kitchen updates only.
|
||||
/// VI: Tham gia group KDS để nhận cập nhật bếp.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Join a POS terminal group.
|
||||
/// VI: Tham gia group thiết bị POS.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave a shop group.
|
||||
/// VI: Rời group shop.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave a KDS group.
|
||||
/// VI: Rời group KDS.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave a POS terminal group.
|
||||
/// VI: Rời group thiết bị POS.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PosNotificationService : IPosNotificationService
|
||||
{
|
||||
private readonly IHubContext<PosHub, IPosHubClient> _hubContext;
|
||||
private readonly ILogger<PosNotificationService> _logger;
|
||||
|
||||
public PosNotificationService(
|
||||
IHubContext<PosHub, IPosHubClient> hubContext,
|
||||
ILogger<PosNotificationService> logger)
|
||||
{
|
||||
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
|
||||
<!-- EN: SignalR for real-time POS/KDS communication / VI: SignalR cho real-time POS/KDS -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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<OrderService.Infrastructure.ExternalServices.WalletServiceClient>(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<ILineItemStrategy, RetailStrategy>();
|
||||
builder.Services.AddTransient<ILineItemStrategy, ServiceStrategy>();
|
||||
@@ -101,6 +112,45 @@ try
|
||||
// EN: Add FluentValidation / VI: Thêm FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
// 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<IUserIdProvider, ClaimsUserIdProvider>();
|
||||
|
||||
// EN: Register POS notification service and connection manager
|
||||
// VI: Đăng ký POS notification service và connection manager
|
||||
builder.Services.AddSingleton<PosConnectionManager>();
|
||||
builder.Services.AddScoped<IPosNotificationService, PosNotificationService>();
|
||||
|
||||
// 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<string[]>()
|
||||
?? ["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<PosHub>("/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
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -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<OrderItem> _items = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -71,6 +76,30 @@ public class Order : Entity, IAggregateRoot
|
||||
public string? DiscountType => _discountType;
|
||||
public string? DiscountReference => _discountReference;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment method used (cash, card, vnpay, momo).
|
||||
/// VI: Phương thức thanh toán đã dùng (cash, card, vnpay, momo).
|
||||
/// </summary>
|
||||
public string? PaymentMethod => _paymentMethod;
|
||||
|
||||
/// <summary>
|
||||
/// 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ẻ.
|
||||
/// </summary>
|
||||
public string? TransactionId => _transactionId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Amount tendered by customer (for cash payments).
|
||||
/// VI: Số tiền khách đưa (cho thanh toán tiền mặt).
|
||||
/// </summary>
|
||||
public decimal? AmountTendered => _amountTendered;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public decimal? ChangeAmount => _changeAmount;
|
||||
|
||||
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
@@ -145,14 +174,55 @@ public class Order : Entity, IAggregateRoot
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -47,6 +47,12 @@ public class OrderStatus : Enumeration
|
||||
/// </summary>
|
||||
public static readonly OrderStatus Cancelled = new(6, nameof(Cancelled));
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static readonly OrderStatus PaymentPending = new(7, nameof(PaymentPending));
|
||||
|
||||
public OrderStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ public record OrderPaidDomainEvent(Order Order) : INotification;
|
||||
/// </summary>
|
||||
public record OrderCompletedDomainEvent(Order Order) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record OrderPaymentPendingDomainEvent(Order Order) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when an order is cancelled.
|
||||
/// VI: Domain event phát ra khi đơn hàng bị hủy.
|
||||
|
||||
@@ -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<IRequestManager, RequestManager>();
|
||||
|
||||
// EN: Register external service clients / VI: Đăng ký external service clients
|
||||
services.AddScoped<IWalletServiceClient, WalletServiceClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,22 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
.HasColumnName("discount_reference")
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property<string?>("_paymentMethod")
|
||||
.HasColumnName("payment_method")
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property<string?>("_transactionId")
|
||||
.HasColumnName("transaction_id")
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property<decimal?>("_amountTendered")
|
||||
.HasColumnName("amount_tendered")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
builder.Property<decimal?>("_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<Order>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ public class OrderStatusEntityTypeConfiguration : IEntityTypeConfiguration<Order
|
||||
OrderStatus.Paid,
|
||||
OrderStatus.Processing,
|
||||
OrderStatus.Completed,
|
||||
OrderStatus.Cancelled
|
||||
OrderStatus.Cancelled,
|
||||
OrderStatus.PaymentPending
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// EN: Interface for wallet-service client — abstracts payment gateway interaction.
|
||||
// VI: Interface cho wallet-service client — trừu tượng hóa tương tác cổng thanh toán.
|
||||
|
||||
namespace OrderService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IWalletServiceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="orderId">EN: Order ID / VI: Mã đơn hàng</param>
|
||||
/// <param name="amount">EN: Payment amount / VI: Số tiền thanh toán</param>
|
||||
/// <param name="gateway">EN: Payment gateway (vnpay, momo) / VI: Cổng thanh toán (vnpay, momo)</param>
|
||||
/// <param name="returnUrl">EN: URL to redirect after payment / VI: URL redirect sau thanh toán</param>
|
||||
/// <param name="ipAddress">EN: Client IP address / VI: Địa chỉ IP client</param>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: Payment creation response with URL and transaction ID / VI: Response tạo thanh toán với URL và mã giao dịch</returns>
|
||||
Task<CreatePaymentResponse?> CreatePaymentAsync(
|
||||
Guid orderId,
|
||||
decimal amount,
|
||||
string gateway,
|
||||
string returnUrl,
|
||||
string ipAddress,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Response from wallet-service payment creation.
|
||||
/// VI: Response từ wallet-service khi tạo thanh toán.
|
||||
/// </summary>
|
||||
public record CreatePaymentResponse(
|
||||
string TransactionId,
|
||||
string PaymentUrl,
|
||||
string Status
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class WalletServiceClient : IWalletServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WalletServiceClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public WalletServiceClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<WalletServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CreatePaymentResponse?> 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<CreatePaymentResponse>(
|
||||
_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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace WalletService.API.Application.Commands.Payments;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a payment through a payment gateway.
|
||||
/// VI: Command de tao thanh toan qua cong thanh toan.
|
||||
/// </summary>
|
||||
public record CreatePaymentCommand(
|
||||
Guid OrderId,
|
||||
decimal Amount,
|
||||
string Currency,
|
||||
string GatewayName,
|
||||
string ReturnUrl,
|
||||
string IpAddress
|
||||
) : IRequest<CreatePaymentResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of creating a payment.
|
||||
/// VI: Ket qua cua viec tao thanh toan.
|
||||
/// </summary>
|
||||
public record CreatePaymentResult(
|
||||
Guid PaymentId,
|
||||
Guid OrderId,
|
||||
decimal Amount,
|
||||
string Currency,
|
||||
string GatewayName,
|
||||
string Status,
|
||||
string? PaymentUrl,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace WalletService.API.Application.Commands.Payments;
|
||||
|
||||
using MediatR;
|
||||
using WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CreatePaymentCommandHandler : IRequestHandler<CreatePaymentCommand, CreatePaymentResult>
|
||||
{
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly IEnumerable<IPaymentGateway> _paymentGateways;
|
||||
private readonly ILogger<CreatePaymentCommandHandler> _logger;
|
||||
|
||||
public CreatePaymentCommandHandler(
|
||||
IPaymentRepository paymentRepository,
|
||||
IEnumerable<IPaymentGateway> paymentGateways,
|
||||
ILogger<CreatePaymentCommandHandler> logger)
|
||||
{
|
||||
_paymentRepository = paymentRepository;
|
||||
_paymentGateways = paymentGateways;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CreatePaymentResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace WalletService.API.Application.Commands.Payments;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to process a payment gateway callback (IPN).
|
||||
/// VI: Command de xu ly callback (IPN) tu cong thanh toan.
|
||||
/// </summary>
|
||||
public record ProcessPaymentCallbackCommand(
|
||||
string GatewayName,
|
||||
IDictionary<string, string> Parameters
|
||||
) : IRequest<ProcessPaymentCallbackResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of processing a payment callback.
|
||||
/// VI: Ket qua xu ly callback thanh toan.
|
||||
/// </summary>
|
||||
public record ProcessPaymentCallbackResult(
|
||||
bool Success,
|
||||
Guid? PaymentId,
|
||||
Guid? OrderId,
|
||||
string Status,
|
||||
string? ErrorMessage
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace WalletService.API.Application.Commands.Payments;
|
||||
|
||||
using MediatR;
|
||||
using WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ProcessPaymentCallbackCommandHandler
|
||||
: IRequestHandler<ProcessPaymentCallbackCommand, ProcessPaymentCallbackResult>
|
||||
{
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly IEnumerable<IPaymentGateway> _paymentGateways;
|
||||
private readonly ILogger<ProcessPaymentCallbackCommandHandler> _logger;
|
||||
|
||||
public ProcessPaymentCallbackCommandHandler(
|
||||
IPaymentRepository paymentRepository,
|
||||
IEnumerable<IPaymentGateway> paymentGateways,
|
||||
ILogger<ProcessPaymentCallbackCommandHandler> logger)
|
||||
{
|
||||
_paymentRepository = paymentRepository;
|
||||
_paymentGateways = paymentGateways;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ProcessPaymentCallbackResult> 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace WalletService.API.Application.Queries.Payments;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a payment by order ID.
|
||||
/// VI: Query de lay thanh toan theo ma don hang.
|
||||
/// </summary>
|
||||
public record GetPaymentByOrderIdQuery(Guid OrderId) : IRequest<PaymentDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a payment by gateway transaction ID.
|
||||
/// VI: Query de lay thanh toan theo ma giao dich cong thanh toan.
|
||||
/// </summary>
|
||||
public record GetPaymentByTransactionIdQuery(string TransactionId) : IRequest<PaymentDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO representing a payment transaction.
|
||||
/// VI: DTO dai dien cho giao dich thanh toan.
|
||||
/// </summary>
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace WalletService.API.Application.Queries.Payments;
|
||||
|
||||
using MediatR;
|
||||
using WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetPaymentByOrderIdQuery.
|
||||
/// VI: Handler cho GetPaymentByOrderIdQuery.
|
||||
/// </summary>
|
||||
public class GetPaymentByOrderIdQueryHandler : IRequestHandler<GetPaymentByOrderIdQuery, PaymentDto?>
|
||||
{
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly ILogger<GetPaymentByOrderIdQueryHandler> _logger;
|
||||
|
||||
public GetPaymentByOrderIdQueryHandler(
|
||||
IPaymentRepository paymentRepository,
|
||||
ILogger<GetPaymentByOrderIdQueryHandler> logger)
|
||||
{
|
||||
_paymentRepository = paymentRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PaymentDto?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetPaymentByTransactionIdQuery.
|
||||
/// VI: Handler cho GetPaymentByTransactionIdQuery.
|
||||
/// </summary>
|
||||
public class GetPaymentByTransactionIdQueryHandler : IRequestHandler<GetPaymentByTransactionIdQuery, PaymentDto?>
|
||||
{
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly ILogger<GetPaymentByTransactionIdQueryHandler> _logger;
|
||||
|
||||
public GetPaymentByTransactionIdQueryHandler(
|
||||
IPaymentRepository paymentRepository,
|
||||
ILogger<GetPaymentByTransactionIdQueryHandler> logger)
|
||||
{
|
||||
_paymentRepository = paymentRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PaymentDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace WalletService.API.Application.Validations;
|
||||
|
||||
using FluentValidation;
|
||||
using WalletService.API.Application.Commands.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreatePaymentCommand.
|
||||
/// VI: Validator cho CreatePaymentCommand.
|
||||
/// </summary>
|
||||
public class CreatePaymentCommandValidator : AbstractValidator<CreatePaymentCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ProcessPaymentCallbackCommand.
|
||||
/// VI: Validator cho ProcessPaymentCallbackCommand.
|
||||
/// </summary>
|
||||
public class ProcessPaymentCallbackCommandValidator : AbstractValidator<ProcessPaymentCallbackCommand>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for payment gateway operations.
|
||||
/// VI: Controller cho cac thao tac cong thanh toan.
|
||||
/// </summary>
|
||||
[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<PaymentsController> _logger;
|
||||
|
||||
public PaymentsController(IMediator mediator, ILogger<PaymentsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a payment through a payment gateway.
|
||||
/// VI: Tao thanh toan qua cong thanh toan.
|
||||
/// </summary>
|
||||
[HttpPost("create")]
|
||||
[Authorize]
|
||||
[SwaggerOperation(Summary = "Create payment", Description = "Create a payment via payment gateway (VNPay, etc.)")]
|
||||
[SwaggerResponse(201, "Payment created", typeof(ApiResponse<CreatePaymentResult>))]
|
||||
[SwaggerResponse(400, "Invalid request")]
|
||||
public async Task<IActionResult> 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<CreatePaymentResult>(true, "Payment created / Thanh toan da duoc tao", result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get payment by order ID.
|
||||
/// VI: Lay thanh toan theo ma don hang.
|
||||
/// </summary>
|
||||
[HttpGet("{orderId:guid}")]
|
||||
[Authorize]
|
||||
[SwaggerOperation(Summary = "Get payment", Description = "Get payment information by order ID")]
|
||||
[SwaggerResponse(200, "Success", typeof(ApiResponse<PaymentDto>))]
|
||||
[SwaggerResponse(404, "Payment not found")]
|
||||
public async Task<IActionResult> GetPaymentByOrderId(
|
||||
Guid orderId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _mediator.Send(new GetPaymentByOrderIdQuery(orderId), cancellationToken);
|
||||
|
||||
if (result == null)
|
||||
return NotFound(new ApiResponse<object>(false, "Payment not found / Khong tim thay thanh toan"));
|
||||
|
||||
return Ok(new ApiResponse<PaymentDto>(true, "Success", result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay IPN callback endpoint (called by VN Pay server).
|
||||
/// VI: Endpoint IPN callback VN Pay (duoc goi boi server VN Pay).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<IActionResult> 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" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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<IActionResult> 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<ProcessPaymentCallbackResult>(
|
||||
result.Success,
|
||||
result.Success
|
||||
? "Payment successful / Thanh toan thanh cong"
|
||||
: "Payment failed / Thanh toan that bai",
|
||||
result));
|
||||
}
|
||||
}
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request DTO for creating a payment.
|
||||
/// VI: DTO yeu cau de tao thanh toan.
|
||||
/// </summary>
|
||||
public record CreatePaymentRequest(
|
||||
Guid OrderId,
|
||||
decimal Amount,
|
||||
string? Currency,
|
||||
string? GatewayName,
|
||||
string ReturnUrl
|
||||
);
|
||||
|
||||
#endregion
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Abstraction for payment gateway integrations.
|
||||
/// VI: Abstraction cho tich hop cong thanh toan.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Each payment gateway (VNPay, MoMo, ZaloPay, etc.) implements this interface.
|
||||
/// VI: Moi cong thanh toan (VNPay, MoMo, ZaloPay, v.v.) implement interface nay.
|
||||
/// </remarks>
|
||||
public interface IPaymentGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The name of this payment gateway.
|
||||
/// VI: Ten cua cong thanh toan nay.
|
||||
/// </summary>
|
||||
string GatewayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a payment request and get the payment URL.
|
||||
/// VI: Tao yeu cau thanh toan va lay URL thanh toan.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Payment request details / VI: Chi tiet yeu cau thanh toan</param>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token huy</param>
|
||||
/// <returns>EN: Payment result with payment URL / VI: Ket qua thanh toan voi URL thanh toan</returns>
|
||||
Task<PaymentResult> CreatePaymentAsync(PaymentRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query the status of an existing payment transaction.
|
||||
/// VI: Truy van trang thai cua giao dich thanh toan hien co.
|
||||
/// </summary>
|
||||
/// <param name="transactionId">EN: Transaction identifier / VI: Ma giao dich</param>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token huy</param>
|
||||
/// <returns>EN: Payment result with current status / VI: Ket qua thanh toan voi trang thai hien tai</returns>
|
||||
Task<PaymentResult> QueryPaymentAsync(string transactionId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request a refund for a completed payment.
|
||||
/// VI: Yeu cau hoan tien cho giao dich da hoan thanh.
|
||||
/// </summary>
|
||||
/// <param name="transactionId">EN: Transaction identifier / VI: Ma giao dich</param>
|
||||
/// <param name="amount">EN: Amount to refund / VI: So tien hoan</param>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token huy</param>
|
||||
/// <returns>EN: Payment result with refund status / VI: Ket qua thanh toan voi trang thai hoan tien</returns>
|
||||
Task<PaymentResult> RefundAsync(string transactionId, decimal amount, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate a callback/IPN from the payment gateway.
|
||||
/// VI: Xac thuc callback/IPN tu cong thanh toan.
|
||||
/// </summary>
|
||||
/// <param name="parameters">EN: Callback parameters / VI: Cac tham so callback</param>
|
||||
/// <param name="secureHash">EN: Secure hash from the gateway / VI: Hash bao mat tu cong thanh toan</param>
|
||||
/// <returns>EN: True if the callback is valid / VI: True neu callback hop le</returns>
|
||||
bool ValidateCallback(IDictionary<string, string> parameters, string secureHash);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Payment aggregate.
|
||||
/// VI: Interface repository cho aggregate Payment.
|
||||
/// </summary>
|
||||
public interface IPaymentRepository : IRepository<Payment>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get payment by ID.
|
||||
/// VI: Lay thanh toan theo ID.
|
||||
/// </summary>
|
||||
Task<Payment?> GetByIdAsync(Guid paymentId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get payment by order ID.
|
||||
/// VI: Lay thanh toan theo ma don hang.
|
||||
/// </summary>
|
||||
Task<Payment?> GetByOrderIdAsync(Guid orderId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get payment by gateway transaction ID.
|
||||
/// VI: Lay thanh toan theo ma giao dich cong thanh toan.
|
||||
/// </summary>
|
||||
Task<Payment?> GetByTransactionIdAsync(string transactionId);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
Task<IEnumerable<Payment>> GetPaymentsByOrderIdAsync(Guid orderId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new payment.
|
||||
/// VI: Them thanh toan moi.
|
||||
/// </summary>
|
||||
Payment Add(Payment payment);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing payment.
|
||||
/// VI: Cap nhat thanh toan hien co.
|
||||
/// </summary>
|
||||
void Update(Payment payment);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
namespace WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
using WalletService.Domain.Events;
|
||||
using WalletService.Domain.Exceptions;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: The order identifier associated with this payment.
|
||||
/// VI: Ma don hang lien ket voi thanh toan nay.
|
||||
/// </summary>
|
||||
public Guid OrderId => _orderId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment amount.
|
||||
/// VI: So tien thanh toan.
|
||||
/// </summary>
|
||||
public decimal Amount => _amount;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency code (e.g., VND, USD).
|
||||
/// VI: Ma tien te (vd: VND, USD).
|
||||
/// </summary>
|
||||
public string Currency => _currency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Name of the payment gateway used.
|
||||
/// VI: Ten cong thanh toan su dung.
|
||||
/// </summary>
|
||||
public string GatewayName => _gatewayName;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Transaction ID from the payment gateway.
|
||||
/// VI: Ma giao dich tu cong thanh toan.
|
||||
/// </summary>
|
||||
public string? TransactionId => _transactionId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment URL for customer redirect.
|
||||
/// VI: URL thanh toan de chuyen huong khach hang.
|
||||
/// </summary>
|
||||
public string? PaymentUrl => _paymentUrl;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment status ID for EF Core mapping.
|
||||
/// VI: ID trang thai thanh toan cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId => _statusId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment status.
|
||||
/// VI: Trang thai thanh toan.
|
||||
/// </summary>
|
||||
public PaymentStatus Status => PaymentStatus.FromValue<PaymentStatus>(_statusId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Error code if payment failed.
|
||||
/// VI: Ma loi neu thanh toan that bai.
|
||||
/// </summary>
|
||||
public string? ErrorCode => _errorCode;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Error message if payment failed.
|
||||
/// VI: Thong bao loi neu thanh toan that bai.
|
||||
/// </summary>
|
||||
public string? ErrorMessage => _errorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment creation timestamp.
|
||||
/// VI: Thoi diem tao thanh toan.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment completion timestamp.
|
||||
/// VI: Thoi diem hoan thanh thanh toan.
|
||||
/// </summary>
|
||||
public DateTime? CompletedAt => _completedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core parameterless constructor.
|
||||
/// VI: Constructor khong tham so cho EF Core.
|
||||
/// </summary>
|
||||
protected Payment()
|
||||
{
|
||||
_currency = string.Empty;
|
||||
_gatewayName = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new payment transaction.
|
||||
/// VI: Tao giao dich thanh toan moi.
|
||||
/// </summary>
|
||||
/// <param name="orderId">EN: Order identifier / VI: Ma don hang</param>
|
||||
/// <param name="amount">EN: Payment amount / VI: So tien thanh toan</param>
|
||||
/// <param name="currency">EN: Currency code / VI: Ma tien te</param>
|
||||
/// <param name="gatewayName">EN: Payment gateway name / VI: Ten cong thanh toan</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="paymentUrl">EN: Payment URL for customer / VI: URL thanh toan cho khach hang</param>
|
||||
/// <param name="transactionId">EN: Gateway transaction ID / VI: Ma giao dich cong thanh toan</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the payment successfully.
|
||||
/// VI: Hoan thanh thanh toan thanh cong.
|
||||
/// </summary>
|
||||
/// <param name="transactionId">EN: Gateway transaction ID / VI: Ma giao dich cong thanh toan</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark the payment as failed.
|
||||
/// VI: Danh dau thanh toan that bai.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">EN: Error code from gateway / VI: Ma loi tu cong thanh toan</param>
|
||||
/// <param name="errorMessage">EN: Error message / VI: Thong bao loi</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Refund the payment.
|
||||
/// VI: Hoan tien thanh toan.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="OrderId">EN: The order identifier / VI: Ma don hang</param>
|
||||
/// <param name="Amount">EN: Payment amount / VI: So tien thanh toan</param>
|
||||
/// <param name="Currency">EN: Currency code (e.g., VND) / VI: Ma tien te (vd: VND)</param>
|
||||
/// <param name="Description">EN: Payment description / VI: Mo ta thanh toan</param>
|
||||
/// <param name="ReturnUrl">EN: URL to redirect after payment / VI: URL chuyen huong sau thanh toan</param>
|
||||
/// <param name="IpAddress">EN: Customer IP address / VI: Dia chi IP khach hang</param>
|
||||
public record PaymentRequest(
|
||||
Guid OrderId,
|
||||
decimal Amount,
|
||||
string Currency,
|
||||
string Description,
|
||||
string ReturnUrl,
|
||||
string IpAddress
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Value object representing a payment gateway response.
|
||||
/// VI: Value object dai dien cho ket qua tra ve tu cong thanh toan.
|
||||
/// </summary>
|
||||
/// <param name="Success">EN: Whether the operation succeeded / VI: Thao tac co thanh cong khong</param>
|
||||
/// <param name="TransactionId">EN: Gateway transaction identifier / VI: Ma giao dich cong thanh toan</param>
|
||||
/// <param name="PaymentUrl">EN: URL to redirect customer for payment / VI: URL chuyen huong khach hang de thanh toan</param>
|
||||
/// <param name="ErrorCode">EN: Error code if failed / VI: Ma loi neu that bai</param>
|
||||
/// <param name="ErrorMessage">EN: Error message if failed / VI: Thong bao loi neu that bai</param>
|
||||
/// <param name="GatewayResponse">EN: Raw gateway response data / VI: Du lieu phan hoi tho tu cong thanh toan</param>
|
||||
public record PaymentResult(
|
||||
bool Success,
|
||||
string? TransactionId = null,
|
||||
string? PaymentUrl = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null,
|
||||
IDictionary<string, string>? GatewayResponse = null
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment status enumeration using type-safe enum pattern.
|
||||
/// VI: Enumeration trang thai thanh toan su dung pattern enum an toan kieu.
|
||||
/// </summary>
|
||||
public class PaymentStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Payment has been created but not yet processed.
|
||||
/// VI: Thanh toan da duoc tao nhung chua xu ly.
|
||||
/// </summary>
|
||||
public static PaymentStatus Pending = new(1, nameof(Pending));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment is being processed by the gateway.
|
||||
/// VI: Thanh toan dang duoc xu ly boi cong thanh toan.
|
||||
/// </summary>
|
||||
public static PaymentStatus Processing = new(2, nameof(Processing));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment has been completed successfully.
|
||||
/// VI: Thanh toan da hoan thanh thanh cong.
|
||||
/// </summary>
|
||||
public static PaymentStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment has failed.
|
||||
/// VI: Thanh toan da that bai.
|
||||
/// </summary>
|
||||
public static PaymentStatus Failed = new(4, nameof(Failed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment has been refunded.
|
||||
/// VI: Thanh toan da duoc hoan tien.
|
||||
/// </summary>
|
||||
public static PaymentStatus Refunded = new(5, nameof(Refunded));
|
||||
|
||||
public PaymentStatus(int id, string name) : base(id, name) { }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a payment is completed successfully.
|
||||
/// VI: Domain event duoc phat ra khi thanh toan hoan thanh thanh cong.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new payment is created.
|
||||
/// VI: Domain event duoc phat ra khi mot thanh toan moi duoc tao.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a payment fails.
|
||||
/// VI: Domain event duoc phat ra khi thanh toan that bai.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +40,13 @@ public static class DependencyInjection
|
||||
// VI: Đăng ký repositories
|
||||
services.AddScoped<IWalletRepository, WalletRepository>();
|
||||
services.AddScoped<IPointAccountRepository, PointAccountRepository>();
|
||||
services.AddScoped<IPaymentRepository, PaymentRepository>();
|
||||
|
||||
// EN: Register VN Pay options and gateway
|
||||
// VI: Đăng ký VN Pay options va gateway
|
||||
services.Configure<VnPayOptions>(configuration.GetSection(VnPayOptions.SectionName));
|
||||
services.AddHttpClient<VnPayGateway>();
|
||||
services.AddScoped<IPaymentGateway, VnPayGateway>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace WalletService.Infrastructure.EntityConfigurations;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Payment aggregate.
|
||||
/// VI: Cau hinh EF Core cho aggregate Payment.
|
||||
/// </summary>
|
||||
public class PaymentEntityTypeConfiguration : IEntityTypeConfiguration<Payment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Payment> builder)
|
||||
{
|
||||
builder.ToTable("payments");
|
||||
|
||||
builder.HasKey(p => p.Id);
|
||||
|
||||
builder.Property(p => p.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property<Guid>("_orderId")
|
||||
.HasColumnName("order_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<decimal>("_amount")
|
||||
.HasColumnName("amount")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_currency")
|
||||
.HasColumnName("currency")
|
||||
.HasMaxLength(10)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_gatewayName")
|
||||
.HasColumnName("gateway_name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_transactionId")
|
||||
.HasColumnName("transaction_id")
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property<string?>("_paymentUrl")
|
||||
.HasColumnName("payment_url")
|
||||
.HasMaxLength(2048);
|
||||
|
||||
builder.Property<int>("_statusId")
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_errorCode")
|
||||
.HasColumnName("error_code")
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property<string?>("_errorMessage")
|
||||
.HasColumnName("error_message")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay payment gateway implementation (API v2.1.0).
|
||||
/// VI: Trien khai cong thanh toan VN Pay (API v2.1.0).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// </remarks>
|
||||
public class VnPayGateway : IPaymentGateway
|
||||
{
|
||||
private readonly VnPayOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<VnPayGateway> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay API version.
|
||||
/// VI: Phien ban API VN Pay.
|
||||
/// </summary>
|
||||
private const string VnPayVersion = "2.1.0";
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay command for payment.
|
||||
/// VI: Lenh VN Pay cho thanh toan.
|
||||
/// </summary>
|
||||
private const string VnPayCommandPay = "pay";
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay command for query.
|
||||
/// VI: Lenh VN Pay cho truy van.
|
||||
/// </summary>
|
||||
private const string VnPayCommandQuery = "querydr";
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay command for refund.
|
||||
/// VI: Lenh VN Pay cho hoan tien.
|
||||
/// </summary>
|
||||
private const string VnPayCommandRefund = "refund";
|
||||
|
||||
public string GatewayName => "VNPAY";
|
||||
|
||||
public VnPayGateway(
|
||||
IOptions<VnPayOptions> options,
|
||||
HttpClient httpClient,
|
||||
ILogger<VnPayGateway> logger)
|
||||
{
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PaymentResult> 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<string, string>
|
||||
{
|
||||
{ "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
|
||||
));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PaymentResult> 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<string, string>
|
||||
{
|
||||
{ "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<Dictionary<string, string>>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PaymentResult> 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<string, string>
|
||||
{
|
||||
{ "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<Dictionary<string, string>>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ValidateCallback(IDictionary<string, string> 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<string, string>(
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// EN: Build URL-encoded query string from sorted parameters.
|
||||
/// VI: Xay dung query string duoc URL-encode tu cac tham so da sap xep.
|
||||
/// </summary>
|
||||
private static string BuildQueryString(SortedDictionary<string, string> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Compute HMAC-SHA512 hash.
|
||||
/// VI: Tinh toan hash HMAC-SHA512.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace WalletService.Infrastructure.PaymentGateways;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Configuration options for VN Pay payment gateway.
|
||||
/// VI: Cac tuy chon cau hinh cho cong thanh toan VN Pay.
|
||||
/// </summary>
|
||||
public class VnPayOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Configuration section name.
|
||||
/// VI: Ten section cau hinh.
|
||||
/// </summary>
|
||||
public const string SectionName = "VnPay";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Merchant terminal code assigned by VN Pay.
|
||||
/// VI: Ma terminal merchant duoc VN Pay cap.
|
||||
/// </summary>
|
||||
public string TmnCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Secret key for HMAC-SHA512 hash generation.
|
||||
/// VI: Khoa bi mat de tao hash HMAC-SHA512.
|
||||
/// </summary>
|
||||
public string HashSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay payment URL (sandbox or production).
|
||||
/// VI: URL thanh toan VN Pay (sandbox hoac production).
|
||||
/// </summary>
|
||||
public string PaymentUrl { get; set; } = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Return URL after payment completion.
|
||||
/// VI: URL tra ve sau khi hoan thanh thanh toan.
|
||||
/// </summary>
|
||||
public string ReturnUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: VN Pay API URL for querying transaction status.
|
||||
/// VI: URL API VN Pay de truy van trang thai giao dich.
|
||||
/// </summary>
|
||||
public string ApiUrl { get; set; } = "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction";
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace WalletService.Infrastructure.Repositories;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WalletService.Domain.AggregatesModel.PaymentAggregate;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Payment aggregate.
|
||||
/// VI: Repository implementation cho aggregate Payment.
|
||||
/// </summary>
|
||||
public class PaymentRepository : IPaymentRepository
|
||||
{
|
||||
private readonly WalletServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public PaymentRepository(WalletServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Payment?> GetByIdAsync(Guid paymentId)
|
||||
{
|
||||
return await _context.Payments
|
||||
.FirstOrDefaultAsync(p => p.Id == paymentId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Payment?> GetByOrderIdAsync(Guid orderId)
|
||||
{
|
||||
return await _context.Payments
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.FirstOrDefaultAsync(p => p.OrderId == orderId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Payment?> GetByTransactionIdAsync(string transactionId)
|
||||
{
|
||||
return await _context.Payments
|
||||
.FirstOrDefaultAsync(p => p.TransactionId == transactionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<Payment>> GetPaymentsByOrderIdAsync(Guid orderId)
|
||||
{
|
||||
return await _context.Payments
|
||||
.Where(p => p.OrderId == orderId)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Payment Add(Payment payment)
|
||||
{
|
||||
return _context.Payments.Add(payment).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Payment payment)
|
||||
{
|
||||
_context.Entry(payment).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -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<WalletItem> WalletItems { get; set; } = null!;
|
||||
public DbSet<WalletTransaction> WalletTransactions { get; set; } = null!;
|
||||
public DbSet<HoldItem> WalletHolds { get; set; } = null!;
|
||||
public DbSet<Payment> Payments { get; set; } = null!;
|
||||
public DbSet<PointAccount> PointAccounts { get; set; } = null!;
|
||||
public DbSet<PointTransaction> PointTransactions { get; set; } = null!;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user