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:
Ho Ngoc Hai
2026-03-06 13:28:46 +07:00
parent 5d9a41fde9
commit 8af86e9e89
78 changed files with 4047 additions and 81 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ═══

View File

@@ -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>

View File

@@ -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);

View File

@@ -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"

View File

@@ -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>;

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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));

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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; }

View File

@@ -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");
});
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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
);

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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
);

View File

@@ -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");
}
}

View File

@@ -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
);

View File

@@ -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;
}
}

View File

@@ -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
);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View 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
}

View File

@@ -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)
);
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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": "*"
}

View File

@@ -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;

View File

@@ -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)
{
}

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -36,7 +36,8 @@ public class OrderStatusEntityTypeConfiguration : IEntityTypeConfiguration<Order
OrderStatus.Paid,
OrderStatus.Processing,
OrderStatus.Completed,
OrderStatus.Cancelled
OrderStatus.Cancelled,
OrderStatus.PaymentPending
);
}
}

View File

@@ -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
);

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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}");
}
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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
);

View File

@@ -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) { }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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");
}
}

View File

@@ -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
}

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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!;