diff --git a/ROADMAP.md b/ROADMAP.md
index 20cd4c87..db47ff1c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,7 +11,7 @@
| Metric | Current | Phase 1 Target | Phase 2 Target | Phase 3 Target |
|--------|:-------:|:--------------:|:--------------:|:--------------:|
| Services production-ready | 8/24 | 12/24 | 16/24 | 20/24 |
-| Test coverage (estimated) | ~40% | 70% | 80% | 85% |
+| Test coverage (estimated) | ~45% | 70% | 80% | 85% |
| POS verticals fully working | 2/5 | 2/5 (stable) | 4/5 | 5/5 |
| Payment methods live | 0 | 2 | 3 | 4+ |
| Real-time features | 0 | KDS + Orders | Full POS | Full |
@@ -109,17 +109,18 @@
| 11 | EOD Reports + Daily Close | `TODO` | Frontend Blazor | Phase 1 / W4 | order-service queries |
| 12 | FnB Engine Test Coverage | `DONE` | QA Engineer | Phase 1 / W3 | 96 tests (57 domain + 39 handler) |
| 13 | Cafe Workflow Completion | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue |
+| 14 | Critical Path Unit Tests (inventory, payment, events) | `IN-PROGRESS` | QA Engineer | Phase 1 / W4 | Deduction, payment callback, domain event handlers |
### P2 — Enhancement
| # | Gap | Status | Sprint | Notes |
|:-:|-----|:------:|:------:|-------|
-| 14 | Marketing — Zalo OA | `TODO` | Phase 3 | mkt-zalo-service |
-| 15 | Marketing — Facebook | `TODO` | Phase 3 | mkt-facebook-service |
-| 16 | Ads Platform | `TODO` | Phase 3 | 5 ads services |
-| 17 | Mobile iOS v1 | `TODO` | Phase 3 | app-client-base-swift |
-| 18 | Mobile MAUI v1 | `TODO` | Phase 3 | app-client-base-net |
-| 19 | Observability Stack | `TODO` | Phase 2 | Prometheus + Grafana + Loki |
+| 15 | Marketing — Zalo OA | `TODO` | Phase 3 | mkt-zalo-service |
+| 16 | Marketing — Facebook | `TODO` | Phase 3 | mkt-facebook-service |
+| 17 | Ads Platform | `TODO` | Phase 3 | 5 ads services |
+| 18 | Mobile iOS v1 | `TODO` | Phase 3 | app-client-base-swift |
+| 19 | Mobile MAUI v1 | `TODO` | Phase 3 | app-client-base-net |
+| 20 | Observability Stack | `TODO` | Phase 2 | Prometheus + Grafana + Loki |
---
@@ -146,8 +147,8 @@
|------|-------|:------:|:----------:|
| Kitchen → Inventory auto-deduction | Senior Backend #1 | `DONE` | fnb-engine, inventory |
| Row-level security (all services) | Senior Backend #2 | `DONE` | — |
-| Rate limiting audit | DevOps | `TODO` | — |
-| Input sanitization audit | QA | `TODO` | — |
+| Rate limiting audit | DevOps | `IN-PROGRESS` | — |
+| Input sanitization audit | QA | `IN-PROGRESS` | — |
| FnB Engine unit tests | QA | `DONE` | — |
| Order lifecycle integration tests | QA | `DONE` | 29 tests, WebApplicationFactory |
@@ -155,7 +156,7 @@
| Task | Agent | Status | Depends On |
|------|-------|:------:|:----------:|
-| EOD reports + daily close workflow | Senior Frontend | `TODO` | order-service |
+| EOD reports + daily close workflow | Senior Frontend | `IN-PROGRESS` | order-service |
| Full regression testing | QA | `TODO` | All P0 done |
| Staging K8s deployment | DevOps | `DONE` | 16 manifests + CI/CD |
| Grafana monitoring dashboards | DevOps | `TODO` | Observability stack |
@@ -235,6 +236,24 @@
| Traefik Route (subscriptions) | DevOps | /api/v1/subscriptions → merchant-service |
| Admin Settings 5-Tab UI | Frontend | Tai khoan, Bao mat, Goi dich vu, Thong bao, He thong |
+### 2026-03-06 (Code Review Fixes)
+
+| Task | Agent | Details |
+|------|-------|---------|
+| Code Review — 75 issues identified | All Agents | Backend (16), Frontend (11), Infrastructure (32), Tests (16) |
+| Fix wallet-service EF Config | Backend #1 | Removed 11 conflicting Ignore() calls for mapped backing fields |
+| Fix KitchenTicket constructor | Backend #1 | Removed short constructor that assigned productId=orderItemId, updated 14 test call sites |
+| Fix fire-and-forget inventory deduction | Backend #1 | Replaced Task.Run with direct await for reliable inventory deduction |
+| Implement TenantMiddleware RLS (4 services) | Backend #2 | wallet, fnb, inventory, catalog — PostgreSQL SET LOCAL for RLS |
+| Fix SQL injection pattern in order-service | Backend #2 | Guid.ToString("D") for safe formatting in TenantMiddleware |
+| Add SignalR Hub shop authorization | Backend #2 | ValidateShopAccess() check in PosHub JoinShop/JoinKds/JoinPos |
+| Fix PosDataService false success on error | Frontend | PayOrderWithDetailsAsync now returns Success=false on parse failure |
+| Fix QrPayment timer race condition | Frontend | Added _disposed guard for safe timer disposal |
+| Add [Authorize] to BFF OrderController | Frontend | Require JWT for all BFF order endpoints |
+| PostgreSQL 15 → 16 in docker-compose | DevOps | Match project spec |
+| Add 4 missing databases to init-databases.sh | DevOps | mkt_facebook, mkt_whatsapp, mkt_x, mkt_zalo |
+| Add Traefik routes (wallet, catalog, booking) | DevOps | Plus /api/v1/stock for inventory |
+
### 2026-03-05
| Task | Agent | Details |
@@ -252,7 +271,7 @@
|------|----------|-----------|:------:|
| 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 | Kitchen→Inventory via HTTP + Polly (not message queue) | Simpler, sufficient for MVP, direct await (changed from fire-and-forget after code review) | 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 |
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/EodReport.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/EodReport.razor
new file mode 100644
index 00000000..1429d609
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Reports/EodReport.razor
@@ -0,0 +1,463 @@
+@page "/admin/reports/eod"
+@layout AdminLayout
+@inherits AdminBase
+@inject PosDataService DataService
+@inject IDialogService DialogService
+@inject ISnackbar Snackbar
+@using WebClientTpos.Client.Services
+@using WebClientTpos.Client.Pages.Admin.Shop
+
+@*
+ EN: End-of-Day Report page — daily closing summary with revenue, orders, payment breakdown, and top items.
+ VI: Trang bao cao cuoi ngay — tom tat dong ngay voi doanh thu, don hang, phan tich thanh toan, va san pham ban chay.
+*@
+
+Bao cao cuoi ngay — GoodGo POS
+
+@* ═══ TOP BAR ═══ *@
+
+
+
Bao cao cuoi ngay
+
Tong hop doanh thu va don hang trong ngay
+
+
+
+
+ @if (_loading)
+ {
+
+ }
+ Xem bao cao
+
+
+ Dong ngay
+
+
+
+
+@* ═══ CONTENT ═══ *@
+
+
+ @if (_loading)
+ {
+
+
+
+ }
+ else if (_report == null)
+ {
+
+ Chon ngay va nhan "Xem bao cao" de tai du lieu bao cao cuoi ngay.
+
+ }
+ else
+ {
+ @* ── KPI SUMMARY CARDS ── *@
+
+ @* Total Revenue *@
+
+
+
+
+
+ @FormatVND(_report.TotalRevenue)
+ Tong doanh thu
+
+
+ @* Total Orders *@
+
+
+
+
+
+ @_report.TotalOrders
+ Tong don hang
+
+
+ @* Average Order Value *@
+
+
+
+
+
+ @FormatVND(_report.TotalOrders > 0 ? _report.TotalRevenue / _report.TotalOrders : 0)
+ Gia tri TB / don
+
+
+ @* Discount Total *@
+
+
+
+
+
+ @FormatVND(_report.DiscountTotal)
+ Giam gia
+
+
+ @* Completed Orders *@
+
+
+
+
+
+ @_report.CompletedOrders
+ Hoan thanh
+
+
+ @* Cancelled Orders *@
+
+
+
+
+
+ @_report.CancelledOrders
+ Da huy
+
+
+
+
+ @* ── REVENUE BY PAYMENT METHOD ── *@
+
+ @* Payment Breakdown Chart *@
+
+
+
+ @if (_report.PaymentBreakdown.Any())
+ {
+
+
+
+
+
+ @foreach (var p in _report.PaymentBreakdown)
+ {
+
+
+ @GetPaymentMethodLabel(p.Method)
+ (@p.Count don)
+
+
@FormatVND(p.Amount)
+
+ }
+
+
+ }
+ else
+ {
+
Khong co du lieu thanh toan.
+ }
+
+
+
+ @* Revenue Breakdown Cards *@
+
+
+
+
+
+
+ Tien mat
+
+
@FormatVND(_report.CashRevenue)
+
+
+
+
+ The
+
+
@FormatVND(_report.CardRevenue)
+
+
+
+
+ Truc tuyen
+
+
@FormatVND(_report.OnlineRevenue)
+
+
+
+
+
+ @* ── HOURLY REVENUE CHART ── *@
+
+
+
+ @if (_hourlyChartData.Length > 0)
+ {
+
+ }
+ else
+ {
+
Khong co du lieu doanh thu theo gio.
+ }
+
+
+
+ @* ── TOP 10 ITEMS TABLE ── *@
+
+
+
+ @if (_report.TopItems.Any())
+ {
+
+
+ #
+ Ten san pham
+ So luong
+ Doanh thu
+
+
+ @((_report.TopItems.IndexOf(context) + 1))
+ @context.ItemName
+ @context.Quantity
+ @FormatVND(context.Revenue)
+
+
+ }
+ else
+ {
+
Khong co du lieu san pham.
+ }
+
+
+ }
+
+
+@code {
+ // EN: Selected date for the report / VI: Ngay duoc chon cho bao cao
+ private DateTime? _selectedDate = DateTime.Today;
+ private bool _loading = false;
+ private PosDataService.EodReportInfo? _report;
+
+ // EN: Chart data arrays / VI: Mang du lieu bieu do
+ private double[] _paymentChartData = Array.Empty();
+ private string[] _paymentChartLabels = Array.Empty();
+ private double[] _hourlyChartData = Array.Empty();
+ private string[] _hourlyChartLabels = Array.Empty();
+ private List _hourlyChartSeries = new();
+
+ private ChartOptions _donutOptions = new()
+ {
+ ChartPalette = new[] { "#22C55E", "#3B82F6", "#8B5CF6", "#F59E0B", "#EC4899", "#FF5C00" }
+ };
+
+ private ChartOptions _barOptions = new()
+ {
+ ChartPalette = new[] { "#FF5C00" },
+ YAxisTicks = 5
+ };
+
+ ///
+ /// EN: Get the current shop ID from the admin context.
+ /// VI: Lay shop ID hien tai tu admin context.
+ ///
+ private Guid? GetCurrentShopId()
+ {
+ // EN: Try to get shop ID from query string or AuthStateService context
+ // VI: Thu lay shop ID tu query string hoac AuthStateService context
+ var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
+ var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
+ if (Guid.TryParse(query["shopId"], out var qsShopId))
+ return qsShopId;
+ return null;
+ }
+
+ ///
+ /// EN: Load the EOD report for the selected date.
+ /// VI: Tai bao cao cuoi ngay cho ngay duoc chon.
+ ///
+ private async Task LoadReportAsync()
+ {
+ var shopId = GetCurrentShopId();
+ if (!shopId.HasValue)
+ {
+ Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning);
+ return;
+ }
+
+ if (!_selectedDate.HasValue)
+ {
+ Snackbar.Add("Vui long chon ngay bao cao.", Severity.Warning);
+ return;
+ }
+
+ _loading = true;
+ _report = null;
+ StateHasChanged();
+
+ try
+ {
+ _report = await DataService.GetEodReportAsync(shopId.Value, _selectedDate.Value);
+ if (_report != null)
+ {
+ BuildChartData();
+ Snackbar.Add($"Da tai bao cao ngay {_selectedDate.Value:dd/MM/yyyy}.", Severity.Success);
+ }
+ else
+ {
+ Snackbar.Add("Khong the tai bao cao. Vui long thu lai.", Severity.Error);
+ }
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _loading = false;
+ StateHasChanged();
+ }
+ }
+
+ ///
+ /// EN: Close the business day with confirmation dialog.
+ /// VI: Dong ngay kinh doanh voi dialog xac nhan.
+ ///
+ private async Task CloseDayAsync()
+ {
+ var shopId = GetCurrentShopId();
+ if (!shopId.HasValue || !_selectedDate.HasValue) return;
+
+ var confirmed = await DialogService.ShowMessageBox(
+ "Xac nhan dong ngay",
+ $"Ban co chac chan muon dong ngay {_selectedDate.Value:dd/MM/yyyy}? Hanh dong nay se tao bao cao cuoi ngay chinh thuc.",
+ yesText: "Dong ngay",
+ cancelText: "Huy");
+
+ if (confirmed != true) return;
+
+ _loading = true;
+ StateHasChanged();
+
+ try
+ {
+ var result = await DataService.CloseDayAsync(shopId.Value, _selectedDate.Value);
+ if (result != null && result.Success)
+ {
+ if (result.Report != null)
+ {
+ _report = result.Report;
+ BuildChartData();
+ }
+
+ var message = $"Da dong ngay {_selectedDate.Value:dd/MM/yyyy} thanh cong.";
+ if (result.PendingOrderCount > 0)
+ {
+ message += $" Luu y: {result.PendingOrderCount} don hang van dang cho xu ly.";
+ Snackbar.Add(message, Severity.Warning);
+ }
+ else
+ {
+ Snackbar.Add(message, Severity.Success);
+ }
+ }
+ else
+ {
+ Snackbar.Add(result?.Message ?? "Khong the dong ngay. Vui long thu lai.", Severity.Error);
+ }
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _loading = false;
+ StateHasChanged();
+ }
+ }
+
+ ///
+ /// EN: Build chart data arrays from the report.
+ /// VI: Xay dung mang du lieu bieu do tu bao cao.
+ ///
+ private void BuildChartData()
+ {
+ if (_report == null) return;
+
+ // EN: Payment breakdown donut chart / VI: Bieu do tron phan tich thanh toan
+ if (_report.PaymentBreakdown.Any())
+ {
+ _paymentChartData = _report.PaymentBreakdown.Select(p => (double)p.Amount).ToArray();
+ _paymentChartLabels = _report.PaymentBreakdown.Select(p => GetPaymentMethodLabel(p.Method)).ToArray();
+ }
+ else
+ {
+ _paymentChartData = Array.Empty();
+ _paymentChartLabels = Array.Empty();
+ }
+
+ // EN: Hourly revenue bar chart / VI: Bieu do cot doanh thu theo gio
+ if (_report.HourlyRevenue.Any())
+ {
+ _hourlyChartData = _report.HourlyRevenue.Select(h => (double)h.Revenue).ToArray();
+ _hourlyChartLabels = _report.HourlyRevenue.Select(h => $"{h.Hour:00}:00").ToArray();
+ _hourlyChartSeries = new List
+ {
+ new ChartSeries { Name = "Doanh thu", Data = _hourlyChartData }
+ };
+ }
+ else
+ {
+ _hourlyChartData = Array.Empty();
+ _hourlyChartLabels = Array.Empty();
+ _hourlyChartSeries = new();
+ }
+ }
+
+ ///
+ /// EN: Get Vietnamese label for payment method.
+ /// VI: Lay nhan tieng Viet cho phuong thuc thanh toan.
+ ///
+ private static string GetPaymentMethodLabel(string method) => method.ToLowerInvariant() switch
+ {
+ "cash" => "Tien mat",
+ "card" => "The",
+ "vnpay" => "VNPay",
+ "momo" => "MoMo",
+ "qr" => "QR Code",
+ "transfer" => "Chuyen khoan",
+ "unknown" => "Chua xac dinh",
+ _ => method
+ };
+
+ private static string FormatVND(decimal val) => val.ToString("N0") + " d";
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
index 33e88893..caff00c8 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
@@ -1358,6 +1358,47 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
+ // ═══ EOD REPORTS / DAILY CLOSE ═══
+
+ // EN: EOD report DTO — matches backend EodReportDto
+ // VI: DTO bao cao cuoi ngay — khop voi EodReportDto backend
+ public record EodReportInfo(
+ DateTime ReportDate, Guid ShopId,
+ int TotalOrders, int CompletedOrders, int CancelledOrders,
+ decimal TotalRevenue, decimal CashRevenue, decimal CardRevenue, decimal OnlineRevenue,
+ decimal DiscountTotal,
+ List PaymentBreakdown,
+ List TopItems,
+ List HourlyRevenue);
+
+ public record EodPaymentBreakdownInfo(string Method, int Count, decimal Amount);
+ public record EodTopItemInfo(string ItemName, int Quantity, decimal Revenue);
+ public record EodHourlyRevenueInfo(int Hour, int OrderCount, decimal Revenue);
+
+ // EN: Close day result DTO
+ // VI: DTO ket qua dong ngay
+ public record CloseDayResultInfo(bool Success, EodReportInfo? Report, string? Message, int PendingOrderCount);
+
+ ///
+ /// EN: Get End-of-Day report for a shop on a specific date.
+ /// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the.
+ ///
+ public async Task GetEodReportAsync(Guid shopId, DateTime date)
+ {
+ var url = $"api/bff/reports/eod?shopId={shopId}&date={date:yyyy-MM-dd}";
+ return await GetObjectFromApiAsync(url);
+ }
+
+ ///
+ /// EN: Close the business day and generate final EOD report.
+ /// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung.
+ ///
+ public async Task CloseDayAsync(Guid shopId, DateTime date)
+ {
+ var url = "api/bff/reports/close-day";
+ return await PostAndGetAsync(url, new { shopId, closeDate = date.ToString("yyyy-MM-dd") });
+ }
+
// ═══ SERVICE HEALTH CHECK ═══
public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs);
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs
index 20b221fb..b0c6d520 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs
@@ -45,4 +45,40 @@ public class ReportsController : ControllerBase
if (shopId.HasValue) qs.Add($"shopId={shopId}");
return _order.GetAsync($"/api/v1/reports/top-products?{string.Join("&", qs)}").ProxyAsync();
}
+
+ ///
+ /// EN: Get End-of-Day report for a shop.
+ /// VI: Lay bao cao cuoi ngay cho shop.
+ ///
+ [HttpGet("reports/eod")]
+ public Task GetEodReport(
+ [FromQuery] Guid? shopId = null,
+ [FromQuery] string? date = null)
+ {
+ var qs = new List();
+ if (shopId.HasValue) qs.Add($"shopId={shopId}");
+ if (!string.IsNullOrEmpty(date)) qs.Add($"date={Uri.EscapeDataString(date)}");
+ var query = qs.Any() ? "?" + string.Join("&", qs) : "";
+ return _order.GetAsync($"/api/v1/reports/eod{query}").ProxyAsync();
+ }
+
+ ///
+ /// EN: Close the business day and generate final EOD report.
+ /// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay.
+ ///
+ [HttpPost("reports/close-day")]
+ public Task CloseDay([FromBody] CloseDayBffRequest request)
+ {
+ return _order.PostAsJsonAsync("/api/v1/reports/close-day", new
+ {
+ shopId = request.ShopId,
+ closeDate = request.CloseDate
+ }).ProxyAsync();
+ }
}
+
+///
+/// EN: BFF request for closing the business day.
+/// VI: BFF request de dong ngay kinh doanh.
+///
+public record CloseDayBffRequest(Guid ShopId, string? CloseDate = null);
diff --git a/infra/traefik/dynamic/middlewares.yml b/infra/traefik/dynamic/middlewares.yml
index 88cd5183..022474ad 100644
--- a/infra/traefik/dynamic/middlewares.yml
+++ b/infra/traefik/dynamic/middlewares.yml
@@ -27,10 +27,33 @@ http:
accessControlAllowCredentials: true
accessControlMaxAge: 86400
+ # EN: STRICT rate limit for auth endpoints (login, register, 2FA) - 10 req/min per IP
+ # VI: Rate limit NGHIEM NGAT cho auth endpoints (login, register, 2FA) - 10 req/phut moi IP
auth-ratelimit:
+ rateLimit:
+ average: 10
+ burst: 5
+
+ # EN: STRICT rate limit for payment endpoints - 30 req/min per IP
+ # VI: Rate limit NGHIEM NGAT cho payment endpoints - 30 req/phut moi IP
+ payment-ratelimit:
+ rateLimit:
+ average: 30
+ burst: 10
+
+ # EN: MODERATE rate limit for general API - 100 req/min per IP
+ # VI: Rate limit VUA PHAI cho API chung - 100 req/phut moi IP
+ api-ratelimit:
rateLimit:
average: 100
burst: 50
+ # EN: RELAXED rate limit for SignalR hubs (real-time needs higher limits) - 500 req/min
+ # VI: Rate limit THOAI MAI cho SignalR hubs (real-time can gioi han cao hon) - 500 req/phut
+ hub-ratelimit:
+ rateLimit:
+ average: 500
+ burst: 100
+
compress:
compress: {}
diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml
index d169fc53..71484602 100644
--- a/infra/traefik/dynamic/routes.yml
+++ b/infra/traefik/dynamic/routes.yml
@@ -16,7 +16,7 @@ http:
service: iam-service
priority: 100
middlewares:
- - auth-ratelimit
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -29,7 +29,7 @@ http:
service: iam-service
priority: 150
middlewares:
- - auth-ratelimit
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -60,6 +60,7 @@ http:
service: storage-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -72,6 +73,7 @@ http:
service: membership-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -84,6 +86,7 @@ http:
service: merchant-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -96,6 +99,7 @@ http:
service: order-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -108,6 +112,7 @@ http:
service: order-service
priority: 110
middlewares:
+ - hub-ratelimit
- cors
entryPoints:
- web
@@ -119,6 +124,7 @@ http:
service: fnb-engine
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -131,6 +137,7 @@ http:
service: inventory-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -143,6 +150,7 @@ http:
service: wallet-service
priority: 100
middlewares:
+ - payment-ratelimit
- cors
- secure-headers
entryPoints:
@@ -155,6 +163,7 @@ http:
service: catalog-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
@@ -167,6 +176,7 @@ http:
service: booking-service
priority: 100
middlewares:
+ - api-ratelimit
- cors
- secure-headers
entryPoints:
diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Validations/CatalogCommandValidators.cs b/services/catalog-service-net/src/CatalogService.API/Application/Validations/CatalogCommandValidators.cs
new file mode 100644
index 00000000..d1c8d6d2
--- /dev/null
+++ b/services/catalog-service-net/src/CatalogService.API/Application/Validations/CatalogCommandValidators.cs
@@ -0,0 +1,184 @@
+// EN: Validators for Catalog commands (products, categories).
+// VI: Validators cho cac commands Catalog (san pham, danh muc).
+
+using FluentValidation;
+using CatalogService.API.Application.Commands;
+
+namespace CatalogService.API.Application.Validations;
+
+///
+/// EN: Validator for CreateProductCommand.
+/// VI: Validator cho CreateProductCommand.
+///
+public class CreateProductCommandValidator : AbstractValidator
+{
+ private static readonly string[] ValidProductTypes = ["Physical", "Service", "PreparedFood"];
+
+ public CreateProductCommandValidator()
+ {
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Product name is required / Ten san pham la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("Product name must not exceed 200 characters / Ten san pham khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(2000)
+ .WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu")
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.Price)
+ .GreaterThanOrEqualTo(0)
+ .WithMessage("Price must not be negative / Gia khong duoc am");
+
+ RuleFor(x => x.Type)
+ .NotEmpty()
+ .WithMessage("Product type is required / Loai san pham la bat buoc")
+ .Must(t => ValidProductTypes.Contains(t))
+ .WithMessage("Invalid product type. Valid values: Physical, Service, PreparedFood / Loai san pham khong hop le");
+
+ RuleFor(x => x.Sku)
+ .MaximumLength(100)
+ .WithMessage("SKU must not exceed 100 characters / Ma SKU khong vuot qua 100 ky tu")
+ .When(x => x.Sku != null);
+
+ RuleFor(x => x.ImageUrl)
+ .MaximumLength(2048)
+ .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
+ .When(x => x.ImageUrl != null);
+ }
+}
+
+///
+/// EN: Validator for UpdateProductCommand.
+/// VI: Validator cho UpdateProductCommand.
+///
+public class UpdateProductCommandValidator : AbstractValidator
+{
+ public UpdateProductCommandValidator()
+ {
+ RuleFor(x => x.ProductId)
+ .NotEmpty()
+ .WithMessage("Product ID is required / Product ID la bat buoc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Product name is required / Ten san pham la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("Product name must not exceed 200 characters / Ten san pham khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(2000)
+ .WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu")
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.Price)
+ .GreaterThanOrEqualTo(0)
+ .WithMessage("Price must not be negative / Gia khong duoc am");
+
+ RuleFor(x => x.ImageUrl)
+ .MaximumLength(2048)
+ .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
+ .When(x => x.ImageUrl != null);
+ }
+}
+
+///
+/// EN: Validator for DeleteProductCommand.
+/// VI: Validator cho DeleteProductCommand.
+///
+public class DeleteProductCommandValidator : AbstractValidator
+{
+ public DeleteProductCommandValidator()
+ {
+ RuleFor(x => x.ProductId)
+ .NotEmpty()
+ .WithMessage("Product ID is required / Product ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for CreateCategoryCommand.
+/// VI: Validator cho CreateCategoryCommand.
+///
+public class CreateCategoryCommandValidator : AbstractValidator
+{
+ public CreateCategoryCommandValidator()
+ {
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Category name is required / Ten danh muc la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("Category name must not exceed 200 characters / Ten danh muc khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(1000)
+ .WithMessage("Description must not exceed 1000 characters / Mo ta khong vuot qua 1000 ky tu")
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.DisplayOrder)
+ .GreaterThanOrEqualTo(0)
+ .WithMessage("Display order must not be negative / Thu tu hien thi khong duoc am");
+
+ RuleFor(x => x.ImageUrl)
+ .MaximumLength(2048)
+ .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
+ .When(x => x.ImageUrl != null);
+ }
+}
+
+///
+/// EN: Validator for UpdateCategoryCommand.
+/// VI: Validator cho UpdateCategoryCommand.
+///
+public class UpdateCategoryCommandValidator : AbstractValidator
+{
+ public UpdateCategoryCommandValidator()
+ {
+ RuleFor(x => x.CategoryId)
+ .NotEmpty()
+ .WithMessage("Category ID is required / Category ID la bat buoc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Category name is required / Ten danh muc la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("Category name must not exceed 200 characters / Ten danh muc khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(1000)
+ .WithMessage("Description must not exceed 1000 characters / Mo ta khong vuot qua 1000 ky tu")
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.DisplayOrder)
+ .GreaterThanOrEqualTo(0)
+ .WithMessage("Display order must not be negative / Thu tu hien thi khong duoc am");
+
+ RuleFor(x => x.ImageUrl)
+ .MaximumLength(2048)
+ .WithMessage("Image URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
+ .When(x => x.ImageUrl != null);
+ }
+}
+
+///
+/// EN: Validator for DeleteCategoryCommand.
+/// VI: Validator cho DeleteCategoryCommand.
+///
+public class DeleteCategoryCommandValidator : AbstractValidator
+{
+ public DeleteCategoryCommandValidator()
+ {
+ RuleFor(x => x.CategoryId)
+ .NotEmpty()
+ .WithMessage("Category ID is required / Category ID la bat buoc");
+ }
+}
diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/FnbCommandValidators.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/FnbCommandValidators.cs
new file mode 100644
index 00000000..da148ad5
--- /dev/null
+++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Validations/FnbCommandValidators.cs
@@ -0,0 +1,201 @@
+// EN: Validators for FnB Engine commands (sessions, tables, tickets, reservations).
+// VI: Validators cho cac commands FnB Engine (phien, ban, phieu bep, dat cho).
+
+using FluentValidation;
+using FnbEngine.API.Application.Commands;
+
+namespace FnbEngine.API.Application.Validations;
+
+///
+/// EN: Validator for OpenSessionCommand.
+/// VI: Validator cho OpenSessionCommand.
+///
+public class OpenSessionCommandValidator : AbstractValidator
+{
+ public OpenSessionCommandValidator()
+ {
+ RuleFor(x => x.TableId)
+ .NotEmpty()
+ .WithMessage("Table ID is required / Table ID la bat buoc");
+
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+
+ RuleFor(x => x.GuestCount)
+ .GreaterThan(0)
+ .WithMessage("Guest count must be greater than zero / So khach phai lon hon 0")
+ .LessThanOrEqualTo(100)
+ .WithMessage("Guest count must not exceed 100 / So khach khong vuot qua 100");
+ }
+}
+
+///
+/// EN: Validator for CloseSessionCommand.
+/// VI: Validator cho CloseSessionCommand.
+///
+public class CloseSessionCommandValidator : AbstractValidator
+{
+ public CloseSessionCommandValidator()
+ {
+ RuleFor(x => x.SessionId)
+ .NotEmpty()
+ .WithMessage("Session ID is required / Session ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for ChangeTableStatusCommand.
+/// VI: Validator cho ChangeTableStatusCommand.
+///
+public class ChangeTableStatusCommandValidator : AbstractValidator
+{
+ private static readonly string[] ValidStatuses = ["Available", "Occupied", "Cleaning", "Reserved", "OutOfService"];
+
+ public ChangeTableStatusCommandValidator()
+ {
+ RuleFor(x => x.TableId)
+ .NotEmpty()
+ .WithMessage("Table ID is required / Table ID la bat buoc");
+
+ RuleFor(x => x.Status)
+ .NotEmpty()
+ .WithMessage("Status is required / Trang thai la bat buoc")
+ .MaximumLength(50)
+ .WithMessage("Status must not exceed 50 characters / Trang thai khong vuot qua 50 ky tu")
+ .Must(s => ValidStatuses.Contains(s))
+ .WithMessage("Invalid status. Valid values: Available, Occupied, Cleaning, Reserved, OutOfService / Trang thai khong hop le");
+ }
+}
+
+///
+/// EN: Validator for UpdateTableCommand.
+/// VI: Validator cho UpdateTableCommand.
+///
+public class UpdateTableCommandValidator : AbstractValidator
+{
+ public UpdateTableCommandValidator()
+ {
+ RuleFor(x => x.TableId)
+ .NotEmpty()
+ .WithMessage("Table ID is required / Table ID la bat buoc");
+
+ RuleFor(x => x.Capacity)
+ .GreaterThan(0)
+ .WithMessage("Capacity must be greater than zero / Suc chua phai lon hon 0")
+ .When(x => x.Capacity.HasValue);
+
+ RuleFor(x => x.Zone)
+ .MaximumLength(100)
+ .WithMessage("Zone must not exceed 100 characters / Khu vuc khong vuot qua 100 ky tu")
+ .When(x => x.Zone != null);
+
+ RuleFor(x => x.HourlyRate)
+ .GreaterThanOrEqualTo(0)
+ .WithMessage("Hourly rate must not be negative / Gia theo gio khong duoc am")
+ .When(x => x.HourlyRate.HasValue);
+ }
+}
+
+///
+/// EN: Validator for UpdateTicketStatusCommand.
+/// VI: Validator cho UpdateTicketStatusCommand.
+///
+public class UpdateTicketStatusCommandValidator : AbstractValidator
+{
+ private static readonly string[] ValidStatuses = ["Pending", "InProgress", "Ready", "Served", "Cancelled"];
+
+ public UpdateTicketStatusCommandValidator()
+ {
+ RuleFor(x => x.TicketId)
+ .NotEmpty()
+ .WithMessage("Ticket ID is required / Ticket ID la bat buoc");
+
+ RuleFor(x => x.Status)
+ .NotEmpty()
+ .WithMessage("Status is required / Trang thai la bat buoc")
+ .MaximumLength(50)
+ .WithMessage("Status must not exceed 50 characters / Trang thai khong vuot qua 50 ky tu")
+ .Must(s => ValidStatuses.Contains(s))
+ .WithMessage("Invalid status. Valid values: Pending, InProgress, Ready, Served, Cancelled / Trang thai khong hop le");
+ }
+}
+
+///
+/// EN: Validator for CreateReservationCommand.
+/// VI: Validator cho CreateReservationCommand.
+///
+public class CreateReservationCommandValidator : AbstractValidator
+{
+ public CreateReservationCommandValidator()
+ {
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+
+ RuleFor(x => x.GuestName)
+ .NotEmpty()
+ .WithMessage("Guest name is required / Ten khach la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("Guest name must not exceed 200 characters / Ten khach khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.PartySize)
+ .GreaterThan(0)
+ .WithMessage("Party size must be greater than zero / So nguoi phai lon hon 0")
+ .LessThanOrEqualTo(100)
+ .WithMessage("Party size must not exceed 100 / So nguoi khong vuot qua 100");
+
+ RuleFor(x => x.ReservationTime)
+ .GreaterThan(DateTime.UtcNow.AddMinutes(-5))
+ .WithMessage("Reservation time must be in the future / Thoi gian dat cho phai trong tuong lai");
+
+ RuleFor(x => x.Phone)
+ .MaximumLength(20)
+ .WithMessage("Phone must not exceed 20 characters / So dien thoai khong vuot qua 20 ky tu")
+ .Matches(@"^[\d\+\-\(\)\s]+$")
+ .WithMessage("Invalid phone format / Dinh dang so dien thoai khong hop le")
+ .When(x => x.Phone != null);
+
+ RuleFor(x => x.Note)
+ .MaximumLength(1000)
+ .WithMessage("Note must not exceed 1000 characters / Ghi chu khong vuot qua 1000 ky tu")
+ .When(x => x.Note != null);
+ }
+}
+
+///
+/// EN: Validator for CreateKitchenTicketCommand.
+/// VI: Validator cho CreateKitchenTicketCommand.
+///
+public class CreateKitchenTicketCommandValidator : AbstractValidator
+{
+ public CreateKitchenTicketCommandValidator()
+ {
+ RuleFor(x => x.SessionId)
+ .NotEmpty()
+ .WithMessage("Session ID is required / Session ID la bat buoc");
+
+ RuleFor(x => x.OrderItemId)
+ .NotEmpty()
+ .WithMessage("Order item ID is required / Order item ID la bat buoc");
+
+ RuleFor(x => x.ItemName)
+ .NotEmpty()
+ .WithMessage("Item name is required / Ten mon la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("Item name must not exceed 200 characters / Ten mon khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.Station)
+ .MaximumLength(100)
+ .WithMessage("Station must not exceed 100 characters / Tram bep khong vuot qua 100 ky tu")
+ .When(x => x.Station != null);
+
+ RuleFor(x => x.Priority)
+ .InclusiveBetween(0, 10)
+ .WithMessage("Priority must be between 0 and 10 / Do uu tien phai tu 0 den 10");
+
+ RuleFor(x => x.Quantity)
+ .GreaterThan(0)
+ .WithMessage("Quantity must be greater than zero / So luong phai lon hon 0");
+ }
+}
diff --git a/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs
new file mode 100644
index 00000000..b1cc1e00
--- /dev/null
+++ b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs
@@ -0,0 +1,324 @@
+// EN: Unit tests for KitchenTicketServedDomainEventHandler - inventory deduction on served tickets.
+// VI: Unit tests cho KitchenTicketServedDomainEventHandler - tru kho khi phieu da phuc vu.
+
+using FluentAssertions;
+using FnbEngine.API.Application.IntegrationEvents.EventHandlers;
+using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
+using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
+using FnbEngine.Domain.AggregatesModel.SessionAggregate;
+using FnbEngine.Domain.Events;
+using FnbEngine.Infrastructure.ExternalServices;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace FnbEngine.UnitTests.Application.IntegrationEvents;
+
+///
+/// EN: Unit tests for KitchenTicketServedDomainEventHandler.
+/// VI: Unit tests cho KitchenTicketServedDomainEventHandler.
+///
+public class KitchenTicketServedDomainEventHandlerTests
+{
+ private readonly Mock _recipeRepositoryMock;
+ private readonly Mock _sessionRepositoryMock;
+ private readonly Mock _inventoryClientMock;
+ private readonly Mock> _loggerMock;
+ private readonly KitchenTicketServedDomainEventHandler _handler;
+
+ private readonly Guid _shopId = Guid.NewGuid();
+ private readonly Guid _sessionId = Guid.NewGuid();
+ private readonly Guid _productId = Guid.NewGuid();
+
+ public KitchenTicketServedDomainEventHandlerTests()
+ {
+ _recipeRepositoryMock = new Mock();
+ _sessionRepositoryMock = new Mock();
+ _inventoryClientMock = new Mock();
+ _loggerMock = new Mock>();
+
+ _handler = new KitchenTicketServedDomainEventHandler(
+ _recipeRepositoryMock.Object,
+ _sessionRepositoryMock.Object,
+ _inventoryClientMock.Object,
+ _loggerMock.Object);
+ }
+
+ ///
+ /// EN: Helper to create a KitchenTicket for testing.
+ /// VI: Helper de tao KitchenTicket de test.
+ ///
+ private KitchenTicket CreateTicket(Guid? productId = null, int quantity = 1)
+ {
+ return new KitchenTicket(
+ _sessionId,
+ Guid.NewGuid(),
+ productId ?? _productId,
+ "Pho Bo",
+ quantity,
+ "Kitchen",
+ 0);
+ }
+
+ ///
+ /// EN: Helper to create a Session for testing.
+ /// VI: Helper de tao Session de test.
+ ///
+ private Session CreateSession()
+ {
+ return new Session(Guid.NewGuid(), _shopId, 2);
+ }
+
+ ///
+ /// EN: Helper to create a Recipe with linked ingredients.
+ /// VI: Helper de tao Recipe voi nguyen lieu co lien ket.
+ ///
+ private Recipe CreateRecipeWithLinkedIngredients(int ingredientCount = 2)
+ {
+ var recipe = new Recipe(_shopId, _productId, "Pho Bo Recipe", "Boil broth", 30);
+ for (int i = 0; i < ingredientCount; i++)
+ {
+ recipe.AddIngredient(
+ $"Ingredient {i + 1}",
+ quantity: 100,
+ unit: "g",
+ costPerUnit: 10_000m,
+ inventoryItemId: Guid.NewGuid(),
+ quantityPerServing: 50m);
+ }
+ return recipe;
+ }
+
+ ///
+ /// EN: Helper to create a Recipe with no linked inventory items.
+ /// VI: Helper de tao Recipe khong co lien ket den inventory items.
+ ///
+ private Recipe CreateRecipeWithoutLinkedIngredients()
+ {
+ var recipe = new Recipe(_shopId, _productId, "Simple Drink", "Pour and serve", 2);
+ recipe.AddIngredient("Water", 200, "ml", 0, inventoryItemId: null, quantityPerServing: 0);
+ return recipe;
+ }
+
+ [Fact]
+ public async Task Handle_WithValidEvent_ShouldCallInventoryClient()
+ {
+ // Arrange
+ var ticket = CreateTicket();
+ var session = CreateSession();
+ var recipe = CreateRecipeWithLinkedIngredients();
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ReturnsAsync(session);
+ _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
+ .ReturnsAsync(recipe);
+ _inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ // Act
+ await _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: Should call inventory client exactly once with the correct deduction request.
+ // VI: Nen goi inventory client dung 1 lan voi request tru kho chinh xac.
+ _inventoryClientMock.Verify(
+ c => c.DeductInventoryAsync(
+ It.Is(req =>
+ req.ShopId == _shopId &&
+ req.ReferenceId == ticket.Id &&
+ req.ReferenceType == "KitchenTicket" &&
+ req.Items.Count == 2),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithNoRecipeFound_ShouldSkipDeduction()
+ {
+ // Arrange
+ var ticket = CreateTicket();
+ var session = CreateSession();
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ReturnsAsync(session);
+ _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
+ .ReturnsAsync((Recipe?)null);
+
+ // Act
+ await _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: No recipe means no inventory deduction should be attempted.
+ // VI: Khong co cong thuc nghia la khong nen co gang tru kho.
+ _inventoryClientMock.Verify(
+ c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_WhenInventoryClientFails_ShouldLogErrorNotThrow()
+ {
+ // Arrange
+ var ticket = CreateTicket();
+ var session = CreateSession();
+ var recipe = CreateRecipeWithLinkedIngredients();
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ReturnsAsync(session);
+ _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
+ .ReturnsAsync(recipe);
+ _inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new HttpRequestException("Inventory service unavailable"));
+
+ // Act
+ var action = () => _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: Should NOT throw - inventory failure should not block kitchen workflow.
+ // VI: KHONG nen throw - loi tru kho khong nen chan luong bep.
+ await action.Should().NotThrowAsync();
+
+ _inventoryClientMock.Verify(
+ c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithMultipleIngredients_ShouldDeductAll()
+ {
+ // Arrange
+ var ticket = CreateTicket(quantity: 3);
+ var session = CreateSession();
+ var recipe = CreateRecipeWithLinkedIngredients(ingredientCount: 4);
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ReturnsAsync(session);
+ _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
+ .ReturnsAsync(recipe);
+
+ DeductInventoryRequest? capturedRequest = null;
+ _inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()))
+ .Callback((req, _) => capturedRequest = req)
+ .ReturnsAsync(true);
+
+ // Act
+ await _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: Should send all 4 ingredients in one deduction request.
+ // VI: Nen gui tat ca 4 nguyen lieu trong mot request tru kho.
+ capturedRequest.Should().NotBeNull();
+ capturedRequest!.Items.Should().HaveCount(4);
+
+ // EN: Each ingredient amount should be multiplied by ticket quantity (3).
+ // VI: So luong moi nguyen lieu nen duoc nhan voi so luong phieu (3).
+ capturedRequest.Items.Should().AllSatisfy(item =>
+ {
+ // EN: quantityPerServing=50, ticket.Quantity=3, so Math.Ceiling(50*3)=150
+ // VI: quantityPerServing=50, ticket.Quantity=3, nen Math.Ceiling(50*3)=150
+ item.Amount.Should().Be(150);
+ });
+ }
+
+ [Fact]
+ public async Task Handle_WithNoLinkedInventoryItems_ShouldSkipDeduction()
+ {
+ // Arrange
+ var ticket = CreateTicket();
+ var session = CreateSession();
+ var recipe = CreateRecipeWithoutLinkedIngredients();
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ReturnsAsync(session);
+ _recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
+ .ReturnsAsync(recipe);
+
+ // Act
+ await _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: Recipe with no linked inventory items should not trigger deduction.
+ // VI: Cong thuc khong co nguyen lieu lien ket kho khong nen kich hoat tru kho.
+ _inventoryClientMock.Verify(
+ c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_WithSessionNotFound_ShouldSkipDeduction()
+ {
+ // Arrange
+ var ticket = CreateTicket();
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ReturnsAsync((Session?)null);
+
+ // Act
+ await _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: Missing session means we cannot determine the shop, so skip deduction.
+ // VI: Thieu session nghia la khong the xac dinh shop, nen bo qua tru kho.
+ _recipeRepositoryMock.Verify(
+ r => r.GetByProductIdAndShopAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ _inventoryClientMock.Verify(
+ c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_WhenRepositoryThrows_ShouldNotThrow()
+ {
+ // Arrange
+ var ticket = CreateTicket();
+ var notification = new KitchenTicketServedDomainEvent(ticket);
+
+ _sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Database connection failed"));
+
+ // Act
+ var action = () => _handler.Handle(notification, CancellationToken.None);
+
+ // Assert
+ // EN: Repository failures should be caught and logged, not propagated.
+ // VI: Loi repository nen duoc bat va log, khong nen lan truyen.
+ await action.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public void Constructor_WithNullRecipeRepository_ShouldThrowArgumentNullException()
+ {
+ // Act
+ var action = () => new KitchenTicketServedDomainEventHandler(
+ null!,
+ _sessionRepositoryMock.Object,
+ _inventoryClientMock.Object,
+ _loggerMock.Object);
+
+ // Assert
+ action.Should().Throw()
+ .And.ParamName.Should().Be("recipeRepository");
+ }
+
+ [Fact]
+ public void Constructor_WithNullInventoryClient_ShouldThrowArgumentNullException()
+ {
+ // Act
+ var action = () => new KitchenTicketServedDomainEventHandler(
+ _recipeRepositoryMock.Object,
+ _sessionRepositoryMock.Object,
+ null!,
+ _loggerMock.Object);
+
+ // Assert
+ action.Should().Throw()
+ .And.ParamName.Should().Be("inventoryClient");
+ }
+}
diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/AuthCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/AuthCommandValidators.cs
new file mode 100644
index 00000000..87984f63
--- /dev/null
+++ b/services/iam-service-net/src/IamService.API/Application/Validations/AuthCommandValidators.cs
@@ -0,0 +1,167 @@
+// EN: Validators for Auth commands (external login, 2FA, logout).
+// VI: Validators cho cac commands Auth (external login, 2FA, logout).
+
+using FluentValidation;
+using IamService.API.Application.Commands.Auth;
+
+namespace IamService.API.Application.Validations;
+
+///
+/// EN: Validator for ExternalLoginCommand.
+/// VI: Validator cho ExternalLoginCommand.
+///
+public class ExternalLoginCommandValidator : AbstractValidator
+{
+ private static readonly string[] ValidProviders = ["Google", "Facebook", "Apple", "google", "facebook", "apple"];
+
+ public ExternalLoginCommandValidator()
+ {
+ RuleFor(x => x.Provider)
+ .NotEmpty()
+ .WithMessage("Provider is required / Nha cung cap la bat buoc")
+ .MaximumLength(50)
+ .WithMessage("Provider must not exceed 50 characters / Nha cung cap khong vuot qua 50 ky tu")
+ .Must(p => ValidProviders.Contains(p))
+ .WithMessage("Invalid provider. Valid values: Google, Facebook, Apple / Nha cung cap khong hop le");
+
+ RuleFor(x => x.ProviderUserId)
+ .NotEmpty()
+ .WithMessage("Provider user ID is required / ID nguoi dung tu nha cung cap la bat buoc")
+ .MaximumLength(256)
+ .WithMessage("Provider user ID must not exceed 256 characters / ID nguoi dung khong vuot qua 256 ky tu");
+
+ RuleFor(x => x.Email)
+ .NotEmpty()
+ .WithMessage("Email is required / Email la bat buoc")
+ .EmailAddress()
+ .WithMessage("Invalid email format / Dinh dang email khong hop le")
+ .MaximumLength(256)
+ .WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu");
+
+ RuleFor(x => x.Name)
+ .MaximumLength(200)
+ .WithMessage("Name must not exceed 200 characters / Ten khong vuot qua 200 ky tu")
+ .When(x => x.Name != null);
+
+ RuleFor(x => x.PictureUrl)
+ .MaximumLength(2048)
+ .WithMessage("Picture URL must not exceed 2048 characters / URL hinh anh khong vuot qua 2048 ky tu")
+ .Must(url => url == null || Uri.TryCreate(url, UriKind.Absolute, out _))
+ .WithMessage("Picture URL must be a valid URL / URL hinh anh phai hop le")
+ .When(x => x.PictureUrl != null);
+ }
+}
+
+///
+/// EN: Validator for Enable2FACommand.
+/// VI: Validator cho Enable2FACommand.
+///
+public class Enable2FACommandValidator : AbstractValidator
+{
+ public Enable2FACommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for Verify2FACommand.
+/// VI: Validator cho Verify2FACommand.
+///
+public class Verify2FACommandValidator : AbstractValidator
+{
+ public Verify2FACommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.Code)
+ .NotEmpty()
+ .WithMessage("2FA code is required / Ma 2FA la bat buoc")
+ .MaximumLength(10)
+ .WithMessage("2FA code must not exceed 10 characters / Ma 2FA khong vuot qua 10 ky tu")
+ .Matches(@"^\d{6}$")
+ .WithMessage("2FA code must be 6 digits / Ma 2FA phai la 6 chu so");
+ }
+}
+
+///
+/// EN: Validator for Disable2FACommand.
+/// VI: Validator cho Disable2FACommand.
+///
+public class Disable2FACommandValidator : AbstractValidator
+{
+ public Disable2FACommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.Code)
+ .NotEmpty()
+ .WithMessage("2FA code is required / Ma 2FA la bat buoc")
+ .MaximumLength(10)
+ .WithMessage("2FA code must not exceed 10 characters / Ma 2FA khong vuot qua 10 ky tu")
+ .Matches(@"^\d{6}$")
+ .WithMessage("2FA code must be 6 digits / Ma 2FA phai la 6 chu so");
+ }
+}
+
+///
+/// EN: Validator for LogoutCommand.
+/// VI: Validator cho LogoutCommand.
+///
+public class LogoutCommandValidator : AbstractValidator
+{
+ public LogoutCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for SendVerificationEmailCommand.
+/// VI: Validator cho SendVerificationEmailCommand.
+///
+public class SendVerificationEmailCommandValidator : AbstractValidator
+{
+ public SendVerificationEmailCommandValidator()
+ {
+ RuleFor(x => x.Email)
+ .NotEmpty()
+ .WithMessage("Email is required / Email la bat buoc")
+ .EmailAddress()
+ .WithMessage("Invalid email format / Dinh dang email khong hop le")
+ .MaximumLength(256)
+ .WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu");
+ }
+}
+
+///
+/// EN: Validator for ConfirmEmailCommand.
+/// VI: Validator cho ConfirmEmailCommand.
+///
+public class ConfirmEmailCommandValidator : AbstractValidator
+{
+ public ConfirmEmailCommandValidator()
+ {
+ RuleFor(x => x.Email)
+ .NotEmpty()
+ .WithMessage("Email is required / Email la bat buoc")
+ .EmailAddress()
+ .WithMessage("Invalid email format / Dinh dang email khong hop le")
+ .MaximumLength(256)
+ .WithMessage("Email must not exceed 256 characters / Email khong vuot qua 256 ky tu");
+
+ RuleFor(x => x.Token)
+ .NotEmpty()
+ .WithMessage("Token is required / Token la bat buoc")
+ .MaximumLength(1024)
+ .WithMessage("Token must not exceed 1024 characters / Token khong vuot qua 1024 ky tu");
+ }
+}
diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/RoleCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/RoleCommandValidators.cs
new file mode 100644
index 00000000..462d43fa
--- /dev/null
+++ b/services/iam-service-net/src/IamService.API/Application/Validations/RoleCommandValidators.cs
@@ -0,0 +1,111 @@
+// EN: Validators for Role commands (create, update, delete, assign, remove).
+// VI: Validators cho cac commands Role (tao, cap nhat, xoa, gan, go).
+
+using FluentValidation;
+using IamService.API.Application.Commands.Roles;
+
+namespace IamService.API.Application.Validations;
+
+///
+/// EN: Validator for CreateRoleCommand.
+/// VI: Validator cho CreateRoleCommand.
+///
+public class CreateRoleCommandValidator : AbstractValidator
+{
+ public CreateRoleCommandValidator()
+ {
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Role name is required / Ten role la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu")
+ .Matches(@"^[a-zA-Z0-9_\-\.]+$")
+ .WithMessage("Role name must contain only letters, numbers, underscores, hyphens, and dots / Ten role chi chua chu cai, so, gach duoi, gach ngang va dau cham");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(500)
+ .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu")
+ .When(x => x.Description != null);
+ }
+}
+
+///
+/// EN: Validator for UpdateRoleCommand.
+/// VI: Validator cho UpdateRoleCommand.
+///
+public class UpdateRoleCommandValidator : AbstractValidator
+{
+ public UpdateRoleCommandValidator()
+ {
+ RuleFor(x => x.RoleId)
+ .NotEmpty()
+ .WithMessage("Role ID is required / Role ID la bat buoc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Role name is required / Ten role la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu")
+ .Matches(@"^[a-zA-Z0-9_\-\.]+$")
+ .WithMessage("Role name must contain only letters, numbers, underscores, hyphens, and dots / Ten role chi chua chu cai, so, gach duoi, gach ngang va dau cham");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(500)
+ .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu")
+ .When(x => x.Description != null);
+ }
+}
+
+///
+/// EN: Validator for DeleteRoleCommand.
+/// VI: Validator cho DeleteRoleCommand.
+///
+public class DeleteRoleCommandValidator : AbstractValidator
+{
+ public DeleteRoleCommandValidator()
+ {
+ RuleFor(x => x.RoleId)
+ .NotEmpty()
+ .WithMessage("Role ID is required / Role ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for AssignRoleToUserCommand.
+/// VI: Validator cho AssignRoleToUserCommand.
+///
+public class AssignRoleToUserCommandValidator : AbstractValidator
+{
+ public AssignRoleToUserCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.RoleName)
+ .NotEmpty()
+ .WithMessage("Role name is required / Ten role la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu");
+ }
+}
+
+///
+/// EN: Validator for RemoveRoleFromUserCommand.
+/// VI: Validator cho RemoveRoleFromUserCommand.
+///
+public class RemoveRoleFromUserCommandValidator : AbstractValidator
+{
+ public RemoveRoleFromUserCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.RoleName)
+ .NotEmpty()
+ .WithMessage("Role name is required / Ten role la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Role name must not exceed 100 characters / Ten role khong vuot qua 100 ky tu");
+ }
+}
diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/UserCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/UserCommandValidators.cs
new file mode 100644
index 00000000..039a20d8
--- /dev/null
+++ b/services/iam-service-net/src/IamService.API/Application/Validations/UserCommandValidators.cs
@@ -0,0 +1,45 @@
+// EN: Validators for User commands (update, delete).
+// VI: Validators cho cac commands User (cap nhat, xoa).
+
+using FluentValidation;
+using IamService.API.Application.Commands.Users;
+
+namespace IamService.API.Application.Validations;
+
+///
+/// EN: Validator for UpdateUserCommand.
+/// VI: Validator cho UpdateUserCommand.
+///
+public class UpdateUserCommandValidator : AbstractValidator
+{
+ public UpdateUserCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.FirstName)
+ .MaximumLength(100)
+ .WithMessage("First name must not exceed 100 characters / Ho khong vuot qua 100 ky tu")
+ .When(x => x.FirstName != null);
+
+ RuleFor(x => x.LastName)
+ .MaximumLength(100)
+ .WithMessage("Last name must not exceed 100 characters / Ten khong vuot qua 100 ky tu")
+ .When(x => x.LastName != null);
+ }
+}
+
+///
+/// EN: Validator for DeleteUserCommand.
+/// VI: Validator cho DeleteUserCommand.
+///
+public class DeleteUserCommandValidator : AbstractValidator
+{
+ public DeleteUserCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+ }
+}
diff --git a/services/inventory-service-net/tests/InventoryService.UnitTests/Application/Commands/DeductInventoryCommandHandlerTests.cs b/services/inventory-service-net/tests/InventoryService.UnitTests/Application/Commands/DeductInventoryCommandHandlerTests.cs
new file mode 100644
index 00000000..ab6b5a6e
--- /dev/null
+++ b/services/inventory-service-net/tests/InventoryService.UnitTests/Application/Commands/DeductInventoryCommandHandlerTests.cs
@@ -0,0 +1,426 @@
+// EN: Unit tests for DeductInventoryCommandHandler - bulk inventory deduction with idempotency.
+// VI: Unit tests cho DeductInventoryCommandHandler - tru kho hang loat voi idempotency.
+
+using FluentAssertions;
+using InventoryService.API.Application.Commands.DeductInventory;
+using InventoryService.Domain.AggregatesModel.InventoryAggregate;
+using InventoryService.Domain.SeedWork;
+using InventoryService.Infrastructure.Idempotency;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace InventoryService.UnitTests.Application.Commands;
+
+///
+/// EN: Unit tests for DeductInventoryCommandHandler.
+/// VI: Unit tests cho DeductInventoryCommandHandler.
+///
+public class DeductInventoryCommandHandlerTests
+{
+ private readonly Mock _repositoryMock;
+ private readonly Mock _requestManagerMock;
+ private readonly Mock> _loggerMock;
+ private readonly Mock _unitOfWorkMock;
+ private readonly DeductInventoryCommandHandler _handler;
+
+ public DeductInventoryCommandHandlerTests()
+ {
+ _repositoryMock = new Mock();
+ _requestManagerMock = new Mock();
+ _loggerMock = new Mock>();
+ _unitOfWorkMock = new Mock();
+
+ _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny()))
+ .ReturnsAsync(true);
+ _repositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
+
+ _requestManagerMock.Setup(r => r.ExistAsync(It.IsAny()))
+ .ReturnsAsync(false);
+ _requestManagerMock.Setup(r => r.CreateRequestForCommandAsync(It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ _handler = new DeductInventoryCommandHandler(
+ _repositoryMock.Object,
+ _requestManagerMock.Object,
+ _loggerMock.Object);
+ }
+
+ ///
+ /// EN: Helper to create an InventoryItem with a specified available quantity.
+ /// VI: Helper de tao InventoryItem voi so luong kha dung chi dinh.
+ ///
+ private static InventoryItem CreateInventoryItemWithStock(Guid id, int quantity)
+ {
+ var item = new InventoryItem(Guid.NewGuid(), Guid.NewGuid(), reorderLevel: 5);
+ // EN: Use reflection to set Id and quantity since they use private fields.
+ // VI: Dung reflection de set Id va quantity vi chung dung private fields.
+ typeof(InventoryItem).BaseType!.GetProperty("Id")!.SetValue(item, id);
+ if (quantity > 0)
+ {
+ item.StockIn(quantity, "Initial stock for test");
+ }
+ return item;
+ }
+
+ [Fact]
+ public async Task Handle_WithValidCommand_ShouldDeductInventory()
+ {
+ // Arrange
+ var itemId = Guid.NewGuid();
+ var inventoryItem = CreateInventoryItemWithStock(itemId, 100);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny()))
+ .ReturnsAsync(inventoryItem);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Test deduction",
+ Items: new List
+ {
+ new(itemId, 10, "pcs", "Sugar")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.ItemsDeducted.Should().Be(1);
+ result.ItemsSkipped.Should().Be(0);
+ result.Items.Should().HaveCount(1);
+ result.Items[0].Deducted.Should().BeTrue();
+ result.Items[0].Error.Should().BeNull();
+
+ _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithInsufficientStock_ShouldReturnPartialDeduction()
+ {
+ // Arrange
+ var itemId = Guid.NewGuid();
+ var inventoryItem = CreateInventoryItemWithStock(itemId, 5);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny()))
+ .ReturnsAsync(inventoryItem);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Test partial deduction",
+ Items: new List
+ {
+ new(itemId, 20, "pcs", "Flour")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Should partially deduct what is available (5 of 20 requested).
+ // VI: Nen tru mot phan nhung gi co san (5 cua 20 yeu cau).
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.ItemsDeducted.Should().Be(1);
+ result.Items[0].Deducted.Should().BeTrue();
+ result.Items[0].Error.Should().Contain("Partial deduction");
+ }
+
+ [Fact]
+ public async Task Handle_WithNonExistentItem_ShouldReturnNotFound()
+ {
+ // Arrange
+ var missingItemId = Guid.NewGuid();
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny()))
+ .ReturnsAsync((InventoryItem?)null);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Test not found",
+ Items: new List
+ {
+ new(missingItemId, 5, "pcs", "Missing Item")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Success.Should().BeFalse();
+ result.ItemsDeducted.Should().Be(0);
+ result.ItemsSkipped.Should().Be(1);
+ result.Items[0].Deducted.Should().BeFalse();
+ result.Items[0].Error.Should().Be("Item not found");
+ }
+
+ [Fact]
+ public async Task Handle_WithZeroStockAvailable_ShouldSkipDeduction()
+ {
+ // Arrange
+ var itemId = Guid.NewGuid();
+ var inventoryItem = CreateInventoryItemWithStock(itemId, 0);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny()))
+ .ReturnsAsync(inventoryItem);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Test zero stock",
+ Items: new List
+ {
+ new(itemId, 10, "pcs", "Empty Item")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Item with 0 stock and insufficient stock should be skipped.
+ // VI: Item voi 0 stock va khong du stock nen duoc bo qua.
+ result.Should().NotBeNull();
+ result.Success.Should().BeFalse();
+ result.ItemsSkipped.Should().Be(1);
+ result.Items[0].Deducted.Should().BeFalse();
+ result.Items[0].Error.Should().Contain("Insufficient stock (0 available)");
+ }
+
+ [Fact]
+ public async Task Handle_WithDuplicateRequest_ShouldReturnIdempotentResult()
+ {
+ // Arrange
+ var referenceId = Guid.NewGuid();
+
+ _requestManagerMock.Setup(r => r.ExistAsync(referenceId))
+ .ReturnsAsync(true);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: referenceId,
+ ReferenceType: "KitchenTicket",
+ Reason: "Duplicate test",
+ Items: new List
+ {
+ new(Guid.NewGuid(), 5, "pcs", "Item A")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Duplicate request should be detected and skipped.
+ // VI: Request trung lap nen duoc phat hien va bo qua.
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.ItemsDeducted.Should().Be(0);
+ result.ItemsSkipped.Should().Be(1);
+ result.Items[0].Error.Should().Be("Duplicate request");
+
+ // EN: Should not attempt to get inventory items.
+ // VI: Khong nen co gang lay inventory items.
+ _repositoryMock.Verify(r => r.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_WithMultipleItems_ShouldDeductAll()
+ {
+ // Arrange
+ var itemId1 = Guid.NewGuid();
+ var itemId2 = Guid.NewGuid();
+ var itemId3 = Guid.NewGuid();
+
+ var item1 = CreateInventoryItemWithStock(itemId1, 50);
+ var item2 = CreateInventoryItemWithStock(itemId2, 30);
+ var item3 = CreateInventoryItemWithStock(itemId3, 100);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId1, It.IsAny())).ReturnsAsync(item1);
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId2, It.IsAny())).ReturnsAsync(item2);
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId3, It.IsAny())).ReturnsAsync(item3);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Multi-item deduction",
+ Items: new List
+ {
+ new(itemId1, 10, "g", "Sugar"),
+ new(itemId2, 5, "ml", "Milk"),
+ new(itemId3, 2, "pcs", "Cup")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.ItemsDeducted.Should().Be(3);
+ result.ItemsSkipped.Should().Be(0);
+ result.Items.Should().HaveCount(3);
+ result.Items.Should().AllSatisfy(i => i.Deducted.Should().BeTrue());
+
+ _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithMixedResults_ShouldDeductFoundItemsAndSkipMissing()
+ {
+ // Arrange
+ var existingItemId = Guid.NewGuid();
+ var missingItemId = Guid.NewGuid();
+
+ var existingItem = CreateInventoryItemWithStock(existingItemId, 50);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(existingItemId, It.IsAny()))
+ .ReturnsAsync(existingItem);
+ _repositoryMock.Setup(r => r.GetByIdAsync(missingItemId, It.IsAny()))
+ .ReturnsAsync((InventoryItem?)null);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Mixed test",
+ Items: new List
+ {
+ new(existingItemId, 5, "pcs", "Sugar"),
+ new(missingItemId, 3, "ml", "Missing Ingredient")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Should deduct existing item and skip missing one.
+ // VI: Nen tru item ton tai va bo qua item khong tim thay.
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.ItemsDeducted.Should().Be(1);
+ result.ItemsSkipped.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task Handle_WithItemExceptionDuringDeduction_ShouldContinueWithRemainingItems()
+ {
+ // Arrange
+ var faultyItemId = Guid.NewGuid();
+ var goodItemId = Guid.NewGuid();
+
+ var goodItem = CreateInventoryItemWithStock(goodItemId, 50);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(faultyItemId, It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Database timeout"));
+ _repositoryMock.Setup(r => r.GetByIdAsync(goodItemId, It.IsAny()))
+ .ReturnsAsync(goodItem);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: Guid.NewGuid(),
+ ReferenceType: "KitchenTicket",
+ Reason: "Error resilience test",
+ Items: new List
+ {
+ new(faultyItemId, 5, "pcs", "Faulty Item"),
+ new(goodItemId, 3, "pcs", "Good Item")
+ });
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Should handle error for faulty item but still deduct good item.
+ // VI: Nen xu ly loi cho item loi nhung van tru item tot.
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.ItemsDeducted.Should().Be(1);
+ result.ItemsSkipped.Should().Be(1);
+ result.Items[0].Deducted.Should().BeFalse();
+ result.Items[0].Error.Should().Be("Database timeout");
+ result.Items[1].Deducted.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Constructor_WithNullRepository_ShouldThrowArgumentNullException()
+ {
+ // Act
+ var action = () => new DeductInventoryCommandHandler(
+ null!,
+ _requestManagerMock.Object,
+ _loggerMock.Object);
+
+ // Assert
+ action.Should().Throw()
+ .And.ParamName.Should().Be("repository");
+ }
+
+ [Fact]
+ public void Constructor_WithNullRequestManager_ShouldThrowArgumentNullException()
+ {
+ // Act
+ var action = () => new DeductInventoryCommandHandler(
+ _repositoryMock.Object,
+ null!,
+ _loggerMock.Object);
+
+ // Assert
+ action.Should().Throw()
+ .And.ParamName.Should().Be("requestManager");
+ }
+
+ [Fact]
+ public void Constructor_WithNullLogger_ShouldThrowArgumentNullException()
+ {
+ // Act
+ var action = () => new DeductInventoryCommandHandler(
+ _repositoryMock.Object,
+ _requestManagerMock.Object,
+ null!);
+
+ // Assert
+ action.Should().Throw()
+ .And.ParamName.Should().Be("logger");
+ }
+
+ [Fact]
+ public async Task Handle_ShouldRecordIdempotencyRequest()
+ {
+ // Arrange
+ var referenceId = Guid.NewGuid();
+ var itemId = Guid.NewGuid();
+ var inventoryItem = CreateInventoryItemWithStock(itemId, 50);
+
+ _repositoryMock.Setup(r => r.GetByIdAsync(itemId, It.IsAny()))
+ .ReturnsAsync(inventoryItem);
+
+ var command = new DeductInventoryCommand(
+ ShopId: Guid.NewGuid(),
+ ReferenceId: referenceId,
+ ReferenceType: "KitchenTicket",
+ Reason: "Idempotency test",
+ Items: new List
+ {
+ new(itemId, 5, "pcs", "Item A")
+ });
+
+ // Act
+ await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Should check and record the idempotency request.
+ // VI: Nen kiem tra va ghi nhan idempotency request.
+ _requestManagerMock.Verify(r => r.ExistAsync(referenceId), Times.Once);
+ _requestManagerMock.Verify(
+ r => r.CreateRequestForCommandAsync(referenceId), Times.Once);
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/AdminCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AdminCommandValidators.cs
new file mode 100644
index 00000000..543accbf
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AdminCommandValidators.cs
@@ -0,0 +1,103 @@
+// EN: Validators for Admin commands (approve, reject, suspend, reactivate, ban merchant).
+// VI: Validators cho cac commands Admin (duyet, tu choi, tam dinh chi, kich hoat lai, cam merchant).
+
+using FluentValidation;
+using MerchantService.API.Application.Commands.Admin;
+
+namespace MerchantService.API.Application.Validations;
+
+///
+/// EN: Validator for ApproveMerchantCommand.
+/// VI: Validator cho ApproveMerchantCommand.
+///
+public class ApproveMerchantCommandValidator : AbstractValidator
+{
+ public ApproveMerchantCommandValidator()
+ {
+ RuleFor(x => x.MerchantId)
+ .NotEmpty()
+ .WithMessage("Merchant ID is required / Merchant ID la bat buoc");
+
+ RuleFor(x => x.ApprovedBy)
+ .NotEmpty()
+ .WithMessage("Approver ID is required / ID nguoi duyet la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for RejectMerchantCommand.
+/// VI: Validator cho RejectMerchantCommand.
+///
+public class RejectMerchantCommandValidator : AbstractValidator
+{
+ public RejectMerchantCommandValidator()
+ {
+ RuleFor(x => x.MerchantId)
+ .NotEmpty()
+ .WithMessage("Merchant ID is required / Merchant ID la bat buoc");
+
+ RuleFor(x => x.Reason)
+ .NotEmpty()
+ .WithMessage("Rejection reason is required / Ly do tu choi la bat buoc")
+ .MaximumLength(1000)
+ .WithMessage("Rejection reason must not exceed 1000 characters / Ly do tu choi khong vuot qua 1000 ky tu");
+
+ RuleFor(x => x.RejectedBy)
+ .NotEmpty()
+ .WithMessage("Rejector ID is required / ID nguoi tu choi la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for SuspendMerchantCommand.
+/// VI: Validator cho SuspendMerchantCommand.
+///
+public class SuspendMerchantCommandValidator : AbstractValidator
+{
+ public SuspendMerchantCommandValidator()
+ {
+ RuleFor(x => x.MerchantId)
+ .NotEmpty()
+ .WithMessage("Merchant ID is required / Merchant ID la bat buoc");
+
+ RuleFor(x => x.Reason)
+ .NotEmpty()
+ .WithMessage("Suspension reason is required / Ly do tam dinh chi la bat buoc")
+ .MaximumLength(1000)
+ .WithMessage("Suspension reason must not exceed 1000 characters / Ly do tam dinh chi khong vuot qua 1000 ky tu");
+ }
+}
+
+///
+/// EN: Validator for ReactivateMerchantCommand.
+/// VI: Validator cho ReactivateMerchantCommand.
+///
+public class ReactivateMerchantCommandValidator : AbstractValidator
+{
+ public ReactivateMerchantCommandValidator()
+ {
+ RuleFor(x => x.MerchantId)
+ .NotEmpty()
+ .WithMessage("Merchant ID is required / Merchant ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for BanMerchantCommand.
+/// VI: Validator cho BanMerchantCommand.
+///
+public class BanMerchantCommandValidator : AbstractValidator
+{
+ public BanMerchantCommandValidator()
+ {
+ RuleFor(x => x.MerchantId)
+ .NotEmpty()
+ .WithMessage("Merchant ID is required / Merchant ID la bat buoc");
+
+ RuleFor(x => x.Reason)
+ .NotEmpty()
+ .WithMessage("Ban reason is required / Ly do cam la bat buoc")
+ .MaximumLength(1000)
+ .WithMessage("Ban reason must not exceed 1000 characters / Ly do cam khong vuot qua 1000 ky tu");
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/AttendanceCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AttendanceCommandValidators.cs
new file mode 100644
index 00000000..65cd9bf4
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/AttendanceCommandValidators.cs
@@ -0,0 +1,173 @@
+// EN: Validators for Attendance and Leave Request commands.
+// VI: Validators cho cac commands Cham cong va Nghi phep.
+
+using FluentValidation;
+using MerchantService.API.Application.Commands.Attendance;
+using MerchantService.API.Application.Commands.LeaveRequests;
+using MerchantService.API.Application.Commands.Shops;
+
+namespace MerchantService.API.Application.Validations;
+
+///
+/// EN: Validator for CheckInCommand.
+/// VI: Validator cho CheckInCommand.
+///
+public class CheckInCommandValidator : AbstractValidator
+{
+ public CheckInCommandValidator()
+ {
+ RuleFor(x => x.StaffId)
+ .NotEmpty()
+ .WithMessage("Staff ID is required / Staff ID la bat buoc");
+
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for CheckOutCommand.
+/// VI: Validator cho CheckOutCommand.
+///
+public class CheckOutCommandValidator : AbstractValidator
+{
+ public CheckOutCommandValidator()
+ {
+ RuleFor(x => x.StaffId)
+ .NotEmpty()
+ .WithMessage("Staff ID is required / Staff ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for CreateLeaveRequestCommand.
+/// VI: Validator cho CreateLeaveRequestCommand.
+///
+public class CreateLeaveRequestCommandValidator : AbstractValidator
+{
+ private static readonly string[] ValidLeaveTypes = ["Annual", "Sick", "Personal", "Maternity", "Paternity", "Unpaid"];
+
+ public CreateLeaveRequestCommandValidator()
+ {
+ RuleFor(x => x.StaffId)
+ .NotEmpty()
+ .WithMessage("Staff ID is required / Staff ID la bat buoc");
+
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+
+ RuleFor(x => x.LeaveType)
+ .NotEmpty()
+ .WithMessage("Leave type is required / Loai nghi phep la bat buoc")
+ .MaximumLength(50)
+ .WithMessage("Leave type must not exceed 50 characters / Loai nghi phep khong vuot qua 50 ky tu");
+
+ RuleFor(x => x.StartDate)
+ .NotEmpty()
+ .WithMessage("Start date is required / Ngay bat dau la bat buoc");
+
+ RuleFor(x => x.EndDate)
+ .NotEmpty()
+ .WithMessage("End date is required / Ngay ket thuc la bat buoc")
+ .GreaterThanOrEqualTo(x => x.StartDate)
+ .WithMessage("End date must be on or after start date / Ngay ket thuc phai bang hoac sau ngay bat dau");
+
+ RuleFor(x => x.Reason)
+ .MaximumLength(1000)
+ .WithMessage("Reason must not exceed 1000 characters / Ly do khong vuot qua 1000 ky tu")
+ .When(x => x.Reason != null);
+ }
+}
+
+///
+/// EN: Validator for ApproveLeaveRequestCommand.
+/// VI: Validator cho ApproveLeaveRequestCommand.
+///
+public class ApproveLeaveRequestCommandValidator : AbstractValidator
+{
+ public ApproveLeaveRequestCommandValidator()
+ {
+ RuleFor(x => x.LeaveRequestId)
+ .NotEmpty()
+ .WithMessage("Leave request ID is required / ID yeu cau nghi phep la bat buoc");
+
+ RuleFor(x => x.ApprovedBy)
+ .NotEmpty()
+ .WithMessage("Approver ID is required / ID nguoi duyet la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for RejectLeaveRequestCommand.
+/// VI: Validator cho RejectLeaveRequestCommand.
+///
+public class RejectLeaveRequestCommandValidator : AbstractValidator
+{
+ public RejectLeaveRequestCommandValidator()
+ {
+ RuleFor(x => x.LeaveRequestId)
+ .NotEmpty()
+ .WithMessage("Leave request ID is required / ID yeu cau nghi phep la bat buoc");
+
+ RuleFor(x => x.RejectedBy)
+ .NotEmpty()
+ .WithMessage("Rejector ID is required / ID nguoi tu choi la bat buoc");
+
+ RuleFor(x => x.Reason)
+ .MaximumLength(1000)
+ .WithMessage("Reason must not exceed 1000 characters / Ly do khong vuot qua 1000 ky tu")
+ .When(x => x.Reason != null);
+ }
+}
+
+///
+/// EN: Validator for UpdateShopCommand.
+/// VI: Validator cho UpdateShopCommand.
+///
+public class UpdateShopCommandValidator : AbstractValidator
+{
+ public UpdateShopCommandValidator()
+ {
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("Shop ID is required / Shop ID la bat buoc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Shop name is required / Ten shop la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Shop name must not exceed 100 characters / Ten shop khong vuot qua 100 ky tu");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(2000)
+ .WithMessage("Description must not exceed 2000 characters / Mo ta khong vuot qua 2000 ky tu")
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.Phone)
+ .MaximumLength(20)
+ .WithMessage("Phone must not exceed 20 characters / So dien thoai khong vuot qua 20 ky tu")
+ .When(x => x.Phone != null);
+
+ RuleFor(x => x.Email)
+ .EmailAddress()
+ .WithMessage("Invalid email format / Dinh dang email khong hop le")
+ .When(x => !string.IsNullOrEmpty(x.Email));
+
+ RuleFor(x => x.Website)
+ .MaximumLength(200)
+ .WithMessage("Website must not exceed 200 characters / Website khong vuot qua 200 ky tu")
+ .When(x => x.Website != null);
+
+ RuleFor(x => x.LogoUrl)
+ .MaximumLength(2048)
+ .WithMessage("Logo URL must not exceed 2048 characters / URL logo khong vuot qua 2048 ky tu")
+ .When(x => x.LogoUrl != null);
+
+ RuleFor(x => x.CoverImageUrl)
+ .MaximumLength(2048)
+ .WithMessage("Cover image URL must not exceed 2048 characters / URL anh bia khong vuot qua 2048 ky tu")
+ .When(x => x.CoverImageUrl != null);
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/Reports/CloseDayCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/Reports/CloseDayCommand.cs
new file mode 100644
index 00000000..efe02537
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Commands/Reports/CloseDayCommand.cs
@@ -0,0 +1,101 @@
+// EN: Command to close the business day and generate final report.
+// VI: Command dong ngay kinh doanh va tao bao cao cuoi cung.
+
+using System.Data;
+using Dapper;
+using MediatR;
+using OrderService.API.Application.Queries.Reports;
+
+namespace OrderService.API.Application.Commands.Reports;
+
+///
+/// EN: Command to close the business day for a shop and generate the final EOD report.
+/// VI: Command dong ngay kinh doanh cho shop va tao bao cao cuoi ngay cuoi cung.
+///
+public record CloseDayCommand(
+ Guid ShopId,
+ DateTime CloseDate
+) : IRequest;
+
+///
+/// EN: Result of day close operation with final report.
+/// VI: Ket qua thao tac dong ngay voi bao cao cuoi cung.
+///
+public record CloseDayResult(
+ bool Success,
+ EodReportDto? Report,
+ string? Message,
+ int PendingOrderCount = 0
+);
+
+///
+/// EN: Handler for CloseDayCommand — validates no pending orders and generates final EOD report.
+/// VI: Handler cho CloseDayCommand — kiem tra khong co don hang cho xu ly va tao bao cao cuoi ngay.
+///
+public class CloseDayCommandHandler : IRequestHandler
+{
+ private readonly IDbConnection _connection;
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public CloseDayCommandHandler(
+ IDbConnection connection,
+ IMediator mediator,
+ ILogger logger)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(CloseDayCommand request, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "EN: Closing day for shop {ShopId} on {Date} / VI: Dong ngay cho shop {ShopId} ngay {Date}",
+ request.ShopId, request.CloseDate.ToString("yyyy-MM-dd"));
+
+ var dayStart = request.CloseDate.Date;
+ var dayEnd = dayStart.AddDays(1);
+
+ var parameters = new DynamicParameters();
+ parameters.Add("ShopId", request.ShopId);
+ parameters.Add("DayStart", dayStart);
+ parameters.Add("DayEnd", dayEnd);
+
+ // EN: Check for pending/in-progress orders (status: Draft=1, Validated=2, Paid=3, Processing=4, PaymentPending=7)
+ // VI: Kiem tra don hang dang cho/dang xu ly (trang thai: Draft=1, Validated=2, Paid=3, Processing=4, PaymentPending=7)
+ var pendingSql = @"
+ SELECT COUNT(*)
+ FROM orders o
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @DayStart
+ AND o.created_at < @DayEnd
+ AND o.status_id IN (1, 2, 3, 4, 7)";
+
+ var pendingCount = await _connection.ExecuteScalarAsync(pendingSql, parameters);
+
+ string? warningMessage = null;
+ if (pendingCount > 0)
+ {
+ warningMessage = $"EN: Warning: {pendingCount} order(s) are still pending/in-progress. / " +
+ $"VI: Canh bao: {pendingCount} don hang van dang cho xu ly.";
+ _logger.LogWarning(
+ "EN: Closing day with {Count} pending orders for shop {ShopId} / VI: Dong ngay voi {Count} don hang dang cho cho shop {ShopId}",
+ pendingCount, request.ShopId);
+ }
+
+ // EN: Generate the EOD report via the query handler / VI: Tao bao cao cuoi ngay qua query handler
+ var report = await _mediator.Send(new GetEodReportQuery(request.ShopId, request.CloseDate), cancellationToken);
+
+ _logger.LogInformation(
+ "EN: Day closed for shop {ShopId} — {Orders} orders, {Revenue:C} revenue / " +
+ "VI: Da dong ngay cho shop {ShopId} — {Orders} don hang, {Revenue:C} doanh thu",
+ request.ShopId, report.TotalOrders, report.TotalRevenue);
+
+ return new CloseDayResult(
+ Success: true,
+ Report: report,
+ Message: warningMessage,
+ PendingOrderCount: pendingCount);
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetEodReportQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetEodReportQuery.cs
new file mode 100644
index 00000000..b6ea8cf7
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetEodReportQuery.cs
@@ -0,0 +1,201 @@
+// EN: Query for End-of-Day report data.
+// VI: Query lay du lieu bao cao cuoi ngay.
+
+using System.Data;
+using Dapper;
+using MediatR;
+
+namespace OrderService.API.Application.Queries.Reports;
+
+///
+/// EN: Query for End-of-Day report aggregating orders, revenue, payments, and top items.
+/// VI: Query cho bao cao cuoi ngay tong hop don hang, doanh thu, thanh toan, va san pham ban chay.
+///
+public record GetEodReportQuery(
+ Guid ShopId,
+ DateTime ReportDate
+) : IRequest;
+
+///
+/// EN: End-of-Day report DTO with complete daily summary.
+/// VI: DTO bao cao cuoi ngay voi tom tat day du trong ngay.
+///
+public record EodReportDto(
+ DateTime ReportDate,
+ Guid ShopId,
+ int TotalOrders,
+ int CompletedOrders,
+ int CancelledOrders,
+ decimal TotalRevenue,
+ decimal CashRevenue,
+ decimal CardRevenue,
+ decimal OnlineRevenue,
+ decimal DiscountTotal,
+ List PaymentBreakdown,
+ List TopItems,
+ List HourlyRevenue
+);
+
+///
+/// EN: Payment breakdown by method (class for Dapper compatibility).
+/// VI: Phan tich thanh toan theo phuong thuc (class cho tuong thich Dapper).
+///
+public class EodPaymentBreakdownDto
+{
+ public string Method { get; set; } = string.Empty;
+ public int Count { get; set; }
+ public decimal Amount { get; set; }
+}
+
+///
+/// EN: Top selling item for the day (class for Dapper compatibility).
+/// VI: San pham ban chay trong ngay (class cho tuong thich Dapper).
+///
+public class EodTopItemDto
+{
+ public string ItemName { get; set; } = string.Empty;
+ public int Quantity { get; set; }
+ public decimal Revenue { get; set; }
+}
+
+///
+/// EN: Revenue data per hour (class for Dapper compatibility).
+/// VI: Du lieu doanh thu theo gio (class cho tuong thich Dapper).
+///
+public class EodHourlyRevenueDto
+{
+ public int Hour { get; set; }
+ public int OrderCount { get; set; }
+ public decimal Revenue { get; set; }
+}
+
+///
+/// EN: Handler for GetEodReportQuery — aggregates daily order data using Dapper.
+/// VI: Handler cho GetEodReportQuery — tong hop du lieu don hang hang ngay bang Dapper.
+///
+public class GetEodReportQueryHandler : IRequestHandler
+{
+ private readonly IDbConnection _connection;
+ private readonly ILogger _logger;
+
+ public GetEodReportQueryHandler(IDbConnection connection, ILogger logger)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(GetEodReportQuery request, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "EN: Generating EOD report for shop {ShopId} on {Date} / VI: Tao bao cao cuoi ngay cho shop {ShopId} ngay {Date}",
+ request.ShopId, request.ReportDate.ToString("yyyy-MM-dd"));
+
+ var dayStart = request.ReportDate.Date;
+ var dayEnd = dayStart.AddDays(1);
+
+ var parameters = new DynamicParameters();
+ parameters.Add("ShopId", request.ShopId);
+ parameters.Add("DayStart", dayStart);
+ parameters.Add("DayEnd", dayEnd);
+
+ // EN: Completed status ID = 5, Cancelled status ID = 6 (from OrderStatus enumeration)
+ // VI: ID trang thai hoan thanh = 5, ID trang thai huy = 6 (tu OrderStatus enumeration)
+ parameters.Add("CompletedStatusId", 5);
+ parameters.Add("CancelledStatusId", 6);
+
+ // EN: Aggregate order stats for the day / VI: Thong ke tong hop don hang trong ngay
+ var aggregateSql = @"
+ SELECT
+ COUNT(*) AS TotalOrders,
+ COUNT(*) FILTER (WHERE o.status_id = @CompletedStatusId) AS CompletedOrders,
+ COUNT(*) FILTER (WHERE o.status_id = @CancelledStatusId) AS CancelledOrders,
+ COALESCE(SUM(o.total_amount) FILTER (WHERE o.status_id NOT IN (@CancelledStatusId)), 0) AS TotalRevenue,
+ COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method = 'cash' AND o.status_id NOT IN (@CancelledStatusId)), 0) AS CashRevenue,
+ COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method = 'card' AND o.status_id NOT IN (@CancelledStatusId)), 0) AS CardRevenue,
+ COALESCE(SUM(o.total_amount) FILTER (WHERE o.payment_method NOT IN ('cash', 'card') AND o.payment_method IS NOT NULL AND o.status_id NOT IN (@CancelledStatusId)), 0) AS OnlineRevenue,
+ COALESCE(SUM(o.discount_amount) FILTER (WHERE o.status_id NOT IN (@CancelledStatusId)), 0) AS DiscountTotal
+ FROM orders o
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @DayStart
+ AND o.created_at < @DayEnd";
+
+ var aggregate = await _connection.QuerySingleAsync(aggregateSql, parameters);
+
+ // EN: Payment breakdown by method / VI: Phan tich theo phuong thuc thanh toan
+ var paymentSql = @"
+ SELECT
+ COALESCE(o.payment_method, 'unknown') AS Method,
+ COUNT(*) AS Count,
+ COALESCE(SUM(o.total_amount), 0) AS Amount
+ FROM orders o
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @DayStart
+ AND o.created_at < @DayEnd
+ AND o.status_id NOT IN (@CancelledStatusId)
+ GROUP BY o.payment_method
+ ORDER BY Amount DESC";
+
+ var paymentBreakdown = (await _connection.QueryAsync(paymentSql, parameters)).AsList();
+
+ // EN: Top 10 items by quantity sold / VI: Top 10 san pham theo so luong ban
+ var topItemsSql = @"
+ SELECT
+ oi.product_name AS ItemName,
+ SUM(oi.quantity) AS Quantity,
+ SUM(oi.unit_price * oi.quantity) AS Revenue
+ FROM order_items oi
+ INNER JOIN orders o ON o.id = oi.order_id
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @DayStart
+ AND o.created_at < @DayEnd
+ AND o.status_id NOT IN (@CancelledStatusId)
+ GROUP BY oi.product_name
+ ORDER BY Quantity DESC
+ LIMIT 10";
+
+ var topItems = (await _connection.QueryAsync(topItemsSql, parameters)).AsList();
+
+ // EN: Hourly revenue breakdown / VI: Doanh thu theo tung gio
+ var hourlySql = @"
+ SELECT
+ EXTRACT(HOUR FROM o.created_at)::int AS Hour,
+ COUNT(*) AS OrderCount,
+ COALESCE(SUM(o.total_amount), 0) AS Revenue
+ FROM orders o
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @DayStart
+ AND o.created_at < @DayEnd
+ AND o.status_id NOT IN (@CancelledStatusId)
+ GROUP BY EXTRACT(HOUR FROM o.created_at)
+ ORDER BY Hour";
+
+ var hourlyRevenue = (await _connection.QueryAsync(hourlySql, parameters)).AsList();
+
+ return new EodReportDto(
+ request.ReportDate.Date,
+ request.ShopId,
+ (int)aggregate.TotalOrders,
+ (int)aggregate.CompletedOrders,
+ (int)aggregate.CancelledOrders,
+ aggregate.TotalRevenue,
+ aggregate.CashRevenue,
+ aggregate.CardRevenue,
+ aggregate.OnlineRevenue,
+ aggregate.DiscountTotal,
+ paymentBreakdown,
+ topItems,
+ hourlyRevenue);
+ }
+
+ private record AggregateRow
+ {
+ public long TotalOrders { get; init; }
+ public long CompletedOrders { get; init; }
+ public long CancelledOrders { get; init; }
+ public decimal TotalRevenue { get; init; }
+ public decimal CashRevenue { get; init; }
+ public decimal CardRevenue { get; init; }
+ public decimal OnlineRevenue { get; init; }
+ public decimal DiscountTotal { get; init; }
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CloseDayCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CloseDayCommandValidator.cs
new file mode 100644
index 00000000..3d9339d9
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Validations/CloseDayCommandValidator.cs
@@ -0,0 +1,27 @@
+// EN: Validator for CloseDayCommand.
+// VI: Validator cho CloseDayCommand.
+
+using FluentValidation;
+using OrderService.API.Application.Commands.Reports;
+
+namespace OrderService.API.Application.Validations;
+
+///
+/// EN: Validator for CloseDayCommand.
+/// VI: Validator cho CloseDayCommand.
+///
+public class CloseDayCommandValidator : AbstractValidator
+{
+ public CloseDayCommandValidator()
+ {
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc");
+
+ RuleFor(x => x.CloseDate)
+ .NotEmpty()
+ .WithMessage("EN: Close date is required / VI: Ngay dong la bat buoc")
+ .LessThanOrEqualTo(DateTime.UtcNow.Date.AddDays(1))
+ .WithMessage("EN: Close date cannot be in the future / VI: Ngay dong khong the trong tuong lai");
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderCommandValidator.cs
new file mode 100644
index 00000000..05b77bfb
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderCommandValidator.cs
@@ -0,0 +1,25 @@
+// EN: Validator for CompleteOrderCommand.
+// VI: Validator cho CompleteOrderCommand.
+
+using FluentValidation;
+using OrderService.API.Application.Commands;
+
+namespace OrderService.API.Application.Validations;
+
+///
+/// EN: Validator for CompleteOrderCommand.
+/// VI: Validator cho CompleteOrderCommand.
+///
+public class CompleteOrderCommandValidator : AbstractValidator
+{
+ public CompleteOrderCommandValidator()
+ {
+ RuleFor(x => x.OrderId)
+ .NotEmpty()
+ .WithMessage("EN: Order ID is required / VI: Order ID la bat buoc");
+
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc");
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderPaymentCommandValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderPaymentCommandValidator.cs
new file mode 100644
index 00000000..aae3d673
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Validations/CompleteOrderPaymentCommandValidator.cs
@@ -0,0 +1,32 @@
+// EN: Validator for CompleteOrderPaymentCommand.
+// VI: Validator cho CompleteOrderPaymentCommand.
+
+using FluentValidation;
+using OrderService.API.Application.Commands;
+
+namespace OrderService.API.Application.Validations;
+
+///
+/// EN: Validator for CompleteOrderPaymentCommand.
+/// VI: Validator cho CompleteOrderPaymentCommand.
+///
+public class CompleteOrderPaymentCommandValidator : AbstractValidator
+{
+ public CompleteOrderPaymentCommandValidator()
+ {
+ RuleFor(x => x.OrderId)
+ .NotEmpty()
+ .WithMessage("EN: Order ID is required / VI: Order ID la bat buoc");
+
+ RuleFor(x => x.GatewayTransactionId)
+ .NotEmpty()
+ .WithMessage("EN: Gateway transaction ID is required / VI: Ma giao dich cong thanh toan la bat buoc")
+ .MaximumLength(200)
+ .WithMessage("EN: Gateway transaction ID must not exceed 200 characters / VI: Ma giao dich khong vuot qua 200 ky tu");
+
+ RuleFor(x => x.GatewayResponseCode)
+ .MaximumLength(50)
+ .WithMessage("EN: Gateway response code must not exceed 50 characters / VI: Ma phan hoi khong vuot qua 50 ky tu")
+ .When(x => x.GatewayResponseCode != null);
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/GetEodReportQueryValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/GetEodReportQueryValidator.cs
new file mode 100644
index 00000000..c61080e6
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Validations/GetEodReportQueryValidator.cs
@@ -0,0 +1,27 @@
+// EN: Validator for GetEodReportQuery.
+// VI: Validator cho GetEodReportQuery.
+
+using FluentValidation;
+using OrderService.API.Application.Queries.Reports;
+
+namespace OrderService.API.Application.Validations;
+
+///
+/// EN: Validator for GetEodReportQuery.
+/// VI: Validator cho GetEodReportQuery.
+///
+public class GetEodReportQueryValidator : AbstractValidator
+{
+ public GetEodReportQueryValidator()
+ {
+ RuleFor(x => x.ShopId)
+ .NotEmpty()
+ .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc");
+
+ RuleFor(x => x.ReportDate)
+ .NotEmpty()
+ .WithMessage("EN: Report date is required / VI: Ngay bao cao la bat buoc")
+ .LessThanOrEqualTo(DateTime.UtcNow.Date.AddDays(1))
+ .WithMessage("EN: Report date cannot be in the future / VI: Ngay bao cao khong the trong tuong lai");
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs
index 2c8ea8a4..47da16b3 100644
--- a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs
+++ b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs
@@ -3,7 +3,9 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
+using OrderService.API.Application.Commands.Reports;
using OrderService.API.Application.Queries;
+using OrderService.API.Application.Queries.Reports;
namespace OrderService.API.Controllers;
@@ -71,4 +73,61 @@ public class ReportsController : ControllerBase
return Ok(result);
}
+
+ ///
+ /// EN: Get End-of-Day report for a shop on a specific date.
+ /// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the.
+ ///
+ [HttpGet("eod")]
+ [ProducesResponseType(typeof(EodReportDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> GetEodReport(
+ [FromQuery] Guid shopId,
+ [FromQuery] DateTime? date = null,
+ CancellationToken cancellationToken = default)
+ {
+ var reportDate = date ?? DateTime.UtcNow.Date;
+
+ _logger.LogInformation(
+ "EN: Getting EOD report for shop {ShopId} on {Date} / VI: Lay bao cao cuoi ngay cho shop {ShopId} ngay {Date}",
+ shopId, reportDate.ToString("yyyy-MM-dd"));
+
+ var query = new GetEodReportQuery(shopId, reportDate);
+ var result = await _mediator.Send(query, cancellationToken);
+
+ return Ok(new { success = true, data = result });
+ }
+
+ ///
+ /// EN: Close the business day and generate final EOD report.
+ /// VI: Dong ngay kinh doanh va tao bao cao cuoi ngay cuoi cung.
+ ///
+ [HttpPost("close-day")]
+ [ProducesResponseType(typeof(CloseDayResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CloseDay(
+ [FromBody] CloseDayRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "EN: Closing day for shop {ShopId} on {Date} / VI: Dong ngay cho shop {ShopId} ngay {Date}",
+ request.ShopId, request.CloseDate?.ToString("yyyy-MM-dd") ?? "today");
+
+ var closeDate = request.CloseDate ?? DateTime.UtcNow.Date;
+ var command = new CloseDayCommand(request.ShopId, closeDate);
+ var result = await _mediator.Send(command, cancellationToken);
+
+ if (!result.Success)
+ {
+ return BadRequest(new { success = false, error = new { code = "CLOSE_DAY_FAILED", message = result.Message } });
+ }
+
+ return Ok(new { success = true, data = result });
+ }
}
+
+///
+/// EN: Request to close the business day.
+/// VI: Yeu cau dong ngay kinh doanh.
+///
+public record CloseDayRequest(Guid ShopId, DateTime? CloseDate = null);
diff --git a/services/storage-service-net/src/StorageService.API/Application/Validations/StorageCommandValidators.cs b/services/storage-service-net/src/StorageService.API/Application/Validations/StorageCommandValidators.cs
new file mode 100644
index 00000000..4ba6574d
--- /dev/null
+++ b/services/storage-service-net/src/StorageService.API/Application/Validations/StorageCommandValidators.cs
@@ -0,0 +1,226 @@
+// EN: Validators for Storage commands (upload, delete, share, admin).
+// VI: Validators cho cac commands Storage (upload, xoa, chia se, admin).
+
+using FluentValidation;
+using StorageService.API.Application.Commands;
+using StorageService.API.Application.Commands.Admin;
+using StorageService.API.Application.Commands.FileShare;
+
+namespace StorageService.API.Application.Validations;
+
+///
+/// EN: Validator for UploadFileCommand.
+/// VI: Validator cho UploadFileCommand.
+///
+public class UploadFileCommandValidator : AbstractValidator
+{
+ private static readonly long MaxFileSizeBytes = 500L * 1024 * 1024; // 500 MB
+
+ public UploadFileCommandValidator()
+ {
+ RuleFor(x => x.FileName)
+ .NotEmpty()
+ .WithMessage("File name is required / Ten file la bat buoc")
+ .MaximumLength(255)
+ .WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu")
+ .Must(name => !name.Contains("..") && !name.Contains('/') && !name.Contains('\\'))
+ .WithMessage("File name contains invalid characters / Ten file chua ky tu khong hop le");
+
+ RuleFor(x => x.ContentType)
+ .NotEmpty()
+ .WithMessage("Content type is required / Content type la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu");
+
+ RuleFor(x => x.FileSizeBytes)
+ .GreaterThan(0)
+ .WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0")
+ .LessThanOrEqualTo(MaxFileSizeBytes)
+ .WithMessage("File size exceeds maximum limit of 500 MB / Kich thuoc file vuot qua gioi han 500 MB");
+
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc")
+ .MaximumLength(128)
+ .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
+
+ RuleFor(x => x.TenantId)
+ .MaximumLength(128)
+ .WithMessage("Tenant ID must not exceed 128 characters / Tenant ID khong vuot qua 128 ky tu")
+ .When(x => x.TenantId != null);
+ }
+}
+
+///
+/// EN: Validator for SignUploadCommand.
+/// VI: Validator cho SignUploadCommand.
+///
+public class SignUploadCommandValidator : AbstractValidator
+{
+ private static readonly long MaxFileSizeBytes = 500L * 1024 * 1024; // 500 MB
+
+ public SignUploadCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc")
+ .MaximumLength(128)
+ .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
+
+ RuleFor(x => x.FileName)
+ .NotEmpty()
+ .WithMessage("File name is required / Ten file la bat buoc")
+ .MaximumLength(255)
+ .WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu")
+ .Must(name => !name.Contains("..") && !name.Contains('/') && !name.Contains('\\'))
+ .WithMessage("File name contains invalid characters / Ten file chua ky tu khong hop le");
+
+ RuleFor(x => x.ContentType)
+ .NotEmpty()
+ .WithMessage("Content type is required / Content type la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu");
+
+ RuleFor(x => x.FileSizeBytes)
+ .GreaterThan(0)
+ .WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0")
+ .LessThanOrEqualTo(MaxFileSizeBytes)
+ .WithMessage("File size exceeds maximum limit of 500 MB / Kich thuoc file vuot qua gioi han 500 MB");
+
+ RuleFor(x => x.TenantId)
+ .MaximumLength(128)
+ .WithMessage("Tenant ID must not exceed 128 characters / Tenant ID khong vuot qua 128 ky tu")
+ .When(x => x.TenantId != null);
+ }
+}
+
+///
+/// EN: Validator for ConfirmUploadCommand.
+/// VI: Validator cho ConfirmUploadCommand.
+///
+public class ConfirmUploadCommandValidator : AbstractValidator
+{
+ public ConfirmUploadCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc")
+ .MaximumLength(128)
+ .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
+
+ RuleFor(x => x.ObjectKey)
+ .NotEmpty()
+ .WithMessage("Object key is required / Object key la bat buoc")
+ .MaximumLength(1024)
+ .WithMessage("Object key must not exceed 1024 characters / Object key khong vuot qua 1024 ky tu");
+
+ RuleFor(x => x.FileName)
+ .NotEmpty()
+ .WithMessage("File name is required / Ten file la bat buoc")
+ .MaximumLength(255)
+ .WithMessage("File name must not exceed 255 characters / Ten file khong vuot qua 255 ky tu");
+
+ RuleFor(x => x.ContentType)
+ .NotEmpty()
+ .WithMessage("Content type is required / Content type la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Content type must not exceed 100 characters / Content type khong vuot qua 100 ky tu");
+
+ RuleFor(x => x.FileSizeBytes)
+ .GreaterThan(0)
+ .WithMessage("File size must be greater than zero / Kich thuoc file phai lon hon 0");
+ }
+}
+
+///
+/// EN: Validator for DeleteFileCommand.
+/// VI: Validator cho DeleteFileCommand.
+///
+public class DeleteFileCommandValidator : AbstractValidator
+{
+ public DeleteFileCommandValidator()
+ {
+ RuleFor(x => x.FileId)
+ .NotEmpty()
+ .WithMessage("File ID is required / File ID la bat buoc");
+
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc")
+ .MaximumLength(128)
+ .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
+ }
+}
+
+///
+/// EN: Validator for CreateFileShareCommand.
+/// VI: Validator cho CreateFileShareCommand.
+///
+public class CreateFileShareCommandValidator : AbstractValidator
+{
+ public CreateFileShareCommandValidator()
+ {
+ RuleFor(x => x.FileId)
+ .NotEmpty()
+ .WithMessage("File ID is required / File ID la bat buoc");
+
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc")
+ .MaximumLength(128)
+ .WithMessage("User ID must not exceed 128 characters / User ID khong vuot qua 128 ky tu");
+
+ RuleFor(x => x.SharedWith)
+ .MaximumLength(256)
+ .WithMessage("Shared with must not exceed 256 characters / Chia se voi khong vuot qua 256 ky tu")
+ .When(x => x.SharedWith != null);
+
+ RuleFor(x => x.Password)
+ .MinimumLength(4)
+ .WithMessage("Password must be at least 4 characters / Mat khau phai it nhat 4 ky tu")
+ .MaximumLength(100)
+ .WithMessage("Password must not exceed 100 characters / Mat khau khong vuot qua 100 ky tu")
+ .When(x => x.Password != null);
+
+ RuleFor(x => x.MaxDownloads)
+ .GreaterThan(0)
+ .WithMessage("Max downloads must be greater than zero / So luot tai toi da phai lon hon 0")
+ .LessThanOrEqualTo(10000)
+ .WithMessage("Max downloads must not exceed 10000 / So luot tai toi da khong vuot qua 10000")
+ .When(x => x.MaxDownloads.HasValue);
+
+ RuleFor(x => x.ExpiresAt)
+ .GreaterThan(DateTime.UtcNow)
+ .WithMessage("Expiration time must be in the future / Thoi gian het han phai trong tuong lai")
+ .When(x => x.ExpiresAt.HasValue);
+ }
+}
+
+///
+/// EN: Validator for UpdateUserQuotaCommand.
+/// VI: Validator cho UpdateUserQuotaCommand.
+///
+public class UpdateUserQuotaCommandValidator : AbstractValidator
+{
+ public UpdateUserQuotaCommandValidator()
+ {
+ RuleFor(x => x.TargetUserId)
+ .NotEmpty()
+ .WithMessage("Target user ID is required / User ID muc tieu la bat buoc")
+ .MaximumLength(128)
+ .WithMessage("Target user ID must not exceed 128 characters / User ID muc tieu khong vuot qua 128 ky tu");
+
+ RuleFor(x => x.MaxStorageBytes)
+ .GreaterThan(0)
+ .WithMessage("Max storage must be greater than zero / Dung luong toi da phai lon hon 0");
+
+ RuleFor(x => x.MaxFileCount)
+ .GreaterThan(0)
+ .WithMessage("Max file count must be greater than zero / So file toi da phai lon hon 0");
+
+ RuleFor(x => x.QuotaTier)
+ .MaximumLength(50)
+ .WithMessage("Quota tier must not exceed 50 characters / Goi quota khong vuot qua 50 ky tu")
+ .When(x => x.QuotaTier != null);
+ }
+}
diff --git a/services/wallet-service-net/src/WalletService.API/Application/Validations/PointCommandValidators.cs b/services/wallet-service-net/src/WalletService.API/Application/Validations/PointCommandValidators.cs
new file mode 100644
index 00000000..53b9691e
--- /dev/null
+++ b/services/wallet-service-net/src/WalletService.API/Application/Validations/PointCommandValidators.cs
@@ -0,0 +1,88 @@
+// EN: Validators for Point commands (earn, spend, create account).
+// VI: Validators cho cac commands Point (tich diem, tieu diem, tao tai khoan).
+
+using FluentValidation;
+using WalletService.API.Application.Commands;
+
+namespace WalletService.API.Application.Validations;
+
+///
+/// EN: Validator for CreatePointAccountCommand.
+/// VI: Validator cho CreatePointAccountCommand.
+///
+public class CreatePointAccountCommandValidator : AbstractValidator
+{
+ public CreatePointAccountCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+ }
+}
+
+///
+/// EN: Validator for EarnPointsCommand.
+/// VI: Validator cho EarnPointsCommand.
+///
+public class EarnPointsCommandValidator : AbstractValidator
+{
+ public EarnPointsCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.Points)
+ .GreaterThan(0)
+ .WithMessage("Points must be greater than zero / Diem phai lon hon 0");
+
+ RuleFor(x => x.Source)
+ .NotEmpty()
+ .WithMessage("Source is required / Nguon la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Source must not exceed 100 characters / Nguon khong vuot qua 100 ky tu");
+
+ RuleFor(x => x.Description)
+ .NotEmpty()
+ .WithMessage("Description is required / Mo ta la bat buoc")
+ .MaximumLength(500)
+ .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
+
+ RuleFor(x => x.ExpiryMonths)
+ .GreaterThan(0)
+ .WithMessage("Expiry months must be greater than zero / So thang het han phai lon hon 0")
+ .LessThanOrEqualTo(120)
+ .WithMessage("Expiry months must not exceed 120 / So thang het han khong vuot qua 120")
+ .When(x => x.ExpiryMonths.HasValue);
+ }
+}
+
+///
+/// EN: Validator for SpendPointsCommand.
+/// VI: Validator cho SpendPointsCommand.
+///
+public class SpendPointsCommandValidator : AbstractValidator
+{
+ public SpendPointsCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.Points)
+ .GreaterThan(0)
+ .WithMessage("Points must be greater than zero / Diem phai lon hon 0");
+
+ RuleFor(x => x.Source)
+ .NotEmpty()
+ .WithMessage("Source is required / Nguon la bat buoc")
+ .MaximumLength(100)
+ .WithMessage("Source must not exceed 100 characters / Nguon khong vuot qua 100 ky tu");
+
+ RuleFor(x => x.Description)
+ .NotEmpty()
+ .WithMessage("Description is required / Mo ta la bat buoc")
+ .MaximumLength(500)
+ .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
+ }
+}
diff --git a/services/wallet-service-net/src/WalletService.API/Application/Validations/WalletCommandValidators.cs b/services/wallet-service-net/src/WalletService.API/Application/Validations/WalletCommandValidators.cs
new file mode 100644
index 00000000..02188ef4
--- /dev/null
+++ b/services/wallet-service-net/src/WalletService.API/Application/Validations/WalletCommandValidators.cs
@@ -0,0 +1,122 @@
+// EN: Validators for Wallet commands (deposit, withdraw, create wallet, exchange).
+// VI: Validators cho cac commands Wallet (nap tien, rut tien, tao vi, quy doi).
+
+using FluentValidation;
+using WalletService.API.Application.Commands;
+
+namespace WalletService.API.Application.Validations;
+
+///
+/// EN: Validator for CreateWalletCommand.
+/// VI: Validator cho CreateWalletCommand.
+///
+public class CreateWalletCommandValidator : AbstractValidator
+{
+ public CreateWalletCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ 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");
+ }
+}
+
+///
+/// EN: Validator for DepositCommand.
+/// VI: Validator cho DepositCommand.
+///
+public class DepositCommandValidator : AbstractValidator
+{
+ public DepositCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.Amount)
+ .GreaterThan(0)
+ .WithMessage("Amount must be greater than zero / So tien phai lon hon 0")
+ .LessThanOrEqualTo(1_000_000_000m)
+ .WithMessage("Amount exceeds maximum limit / So tien vuot qua gioi han toi da");
+
+ RuleFor(x => x.Description)
+ .NotEmpty()
+ .WithMessage("Description is required / Mo ta la bat buoc")
+ .MaximumLength(500)
+ .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
+
+ RuleFor(x => x.ReferenceId)
+ .MaximumLength(100)
+ .WithMessage("Reference ID must not exceed 100 characters / Ma tham chieu khong vuot qua 100 ky tu")
+ .When(x => x.ReferenceId != null);
+ }
+}
+
+///
+/// EN: Validator for WithdrawCommand.
+/// VI: Validator cho WithdrawCommand.
+///
+public class WithdrawCommandValidator : AbstractValidator
+{
+ public WithdrawCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.Amount)
+ .GreaterThan(0)
+ .WithMessage("Amount must be greater than zero / So tien phai lon hon 0")
+ .LessThanOrEqualTo(1_000_000_000m)
+ .WithMessage("Amount exceeds maximum limit / So tien vuot qua gioi han toi da");
+
+ RuleFor(x => x.Description)
+ .NotEmpty()
+ .WithMessage("Description is required / Mo ta la bat buoc")
+ .MaximumLength(500)
+ .WithMessage("Description must not exceed 500 characters / Mo ta khong vuot qua 500 ky tu");
+
+ RuleFor(x => x.ReferenceId)
+ .MaximumLength(100)
+ .WithMessage("Reference ID must not exceed 100 characters / Ma tham chieu khong vuot qua 100 ky tu")
+ .When(x => x.ReferenceId != null);
+ }
+}
+
+///
+/// EN: Validator for ExchangeCommand.
+/// VI: Validator cho ExchangeCommand.
+///
+public class ExchangeCommandValidator : AbstractValidator
+{
+ public ExchangeCommandValidator()
+ {
+ RuleFor(x => x.UserId)
+ .NotEmpty()
+ .WithMessage("User ID is required / User ID la bat buoc");
+
+ RuleFor(x => x.FromAmount)
+ .GreaterThan(0)
+ .WithMessage("From amount must be greater than zero / So tien quy doi phai lon hon 0");
+
+ RuleFor(x => x.FromCurrencyTypeId)
+ .GreaterThan(0)
+ .WithMessage("From currency type is required / Loai tien nguon la bat buoc");
+
+ RuleFor(x => x.ToCurrencyTypeId)
+ .GreaterThan(0)
+ .WithMessage("To currency type is required / Loai tien dich la bat buoc")
+ .NotEqual(x => x.FromCurrencyTypeId)
+ .WithMessage("Cannot exchange to same currency type / Khong the quy doi cung loai tien te");
+
+ RuleFor(x => x.CustomRate)
+ .GreaterThan(0)
+ .WithMessage("Custom rate must be greater than zero / Ty gia tuy chinh phai lon hon 0")
+ .When(x => x.CustomRate.HasValue);
+ }
+}
diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/CreatePaymentCommandHandlerTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/CreatePaymentCommandHandlerTests.cs
new file mode 100644
index 00000000..f2bc87be
--- /dev/null
+++ b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/CreatePaymentCommandHandlerTests.cs
@@ -0,0 +1,251 @@
+// EN: Unit tests for CreatePaymentCommandHandler - payment creation with gateway integration.
+// VI: Unit tests cho CreatePaymentCommandHandler - tao thanh toan voi tich hop cong thanh toan.
+
+using FluentAssertions;
+using MediatR;
+using Moq;
+using WalletService.API.Application.Commands.Payments;
+using WalletService.Domain.AggregatesModel.PaymentAggregate;
+using WalletService.Domain.Events;
+using WalletService.Domain.Exceptions;
+using WalletService.Domain.SeedWork;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace WalletService.UnitTests.Application.Commands;
+
+///
+/// EN: Unit tests for CreatePaymentCommandHandler.
+/// VI: Unit tests cho CreatePaymentCommandHandler.
+///
+public class CreatePaymentCommandHandlerTests
+{
+ private readonly Mock _paymentRepositoryMock;
+ private readonly Mock _vnpayGatewayMock;
+ private readonly Mock _unitOfWorkMock;
+ private readonly Mock> _loggerMock;
+ private readonly CreatePaymentCommandHandler _handler;
+
+ public CreatePaymentCommandHandlerTests()
+ {
+ _paymentRepositoryMock = new Mock();
+ _vnpayGatewayMock = new Mock();
+ _unitOfWorkMock = new Mock();
+ _loggerMock = new Mock>();
+
+ _vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay");
+
+ _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny()))
+ .ReturnsAsync(true);
+ _paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
+ _paymentRepositoryMock.Setup(r => r.Add(It.IsAny()))
+ .Returns((Payment p) => p);
+
+ var gateways = new List { _vnpayGatewayMock.Object };
+
+ _handler = new CreatePaymentCommandHandler(
+ _paymentRepositoryMock.Object,
+ gateways,
+ _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_WithValidPayment_ShouldCreatePayment()
+ {
+ // Arrange
+ _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PaymentResult(
+ Success: true,
+ TransactionId: "TXN123",
+ PaymentUrl: "https://vnpay.vn/pay?txn=TXN123"));
+
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: 100_000m,
+ Currency: "VND",
+ GatewayName: "VNPay",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "127.0.0.1");
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.PaymentId.Should().NotBeEmpty();
+ result.OrderId.Should().Be(command.OrderId);
+ result.Amount.Should().Be(100_000m);
+ result.Currency.Should().Be("VND");
+ result.GatewayName.Should().Be("VNPay");
+ result.Status.Should().Be("Processing");
+ result.PaymentUrl.Should().Be("https://vnpay.vn/pay?txn=TXN123");
+
+ _paymentRepositoryMock.Verify(r => r.Add(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidGateway_ShouldThrowWalletDomainException()
+ {
+ // Arrange
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: 50_000m,
+ Currency: "VND",
+ GatewayName: "NonExistentGateway",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "127.0.0.1");
+
+ // Act
+ var action = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Should throw WalletDomainException for unsupported gateway.
+ // VI: Nen throw WalletDomainException cho cong thanh toan khong ho tro.
+ await action.Should().ThrowAsync()
+ .WithMessage("*NonExistentGateway*not supported*");
+ }
+
+ [Fact]
+ public async Task Handle_WithGatewayFailure_ShouldCreatePaymentWithFailedStatus()
+ {
+ // Arrange
+ _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PaymentResult(
+ Success: false,
+ ErrorCode: "TIMEOUT",
+ ErrorMessage: "Gateway timeout"));
+
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: 200_000m,
+ Currency: "VND",
+ GatewayName: "VNPay",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "127.0.0.1");
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Should create the payment entity but mark it as failed.
+ // VI: Nen tao payment entity nhung danh dau la that bai.
+ result.Should().NotBeNull();
+ result.Status.Should().Be("Failed");
+ result.PaymentUrl.Should().BeNull();
+
+ _paymentRepositoryMock.Verify(r => r.Add(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void Handle_WithZeroAmount_ShouldThrowWalletDomainException()
+ {
+ // Arrange
+ // EN: Payment entity constructor validates amount > 0.
+ // VI: Constructor cua Payment entity validate amount > 0.
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: 0m,
+ Currency: "VND",
+ GatewayName: "VNPay",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "127.0.0.1");
+
+ _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay"));
+
+ // Act
+ var action = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ action.Should().ThrowAsync()
+ .WithMessage("*amount*greater than zero*");
+ }
+
+ [Fact]
+ public void Handle_WithNegativeAmount_ShouldThrowWalletDomainException()
+ {
+ // Arrange
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: -100_000m,
+ Currency: "VND",
+ GatewayName: "VNPay",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "127.0.0.1");
+
+ _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PaymentResult(Success: true, TransactionId: "TXN123", PaymentUrl: "https://vnpay.vn/pay"));
+
+ // Act
+ var action = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ action.Should().ThrowAsync()
+ .WithMessage("*amount*greater than zero*");
+ }
+
+ [Fact]
+ public async Task Handle_ShouldRaiseDomainEvent()
+ {
+ // Arrange
+ _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PaymentResult(
+ Success: true,
+ TransactionId: "TXN456",
+ PaymentUrl: "https://vnpay.vn/pay?txn=TXN456"));
+
+ Payment? capturedPayment = null;
+ _paymentRepositoryMock.Setup(r => r.Add(It.IsAny()))
+ .Callback(p => capturedPayment = p)
+ .Returns((Payment p) => p);
+
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: 150_000m,
+ Currency: "VND",
+ GatewayName: "VNPay",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "127.0.0.1");
+
+ // Act
+ await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Payment constructor raises PaymentCreatedDomainEvent.
+ // VI: Constructor cua Payment phat ra PaymentCreatedDomainEvent.
+ capturedPayment.Should().NotBeNull();
+ capturedPayment!.DomainEvents.Should().NotBeEmpty();
+ capturedPayment.DomainEvents.Should().ContainItemsAssignableTo();
+ }
+
+ [Fact]
+ public async Task Handle_WithSuccessfulGateway_ShouldSetPaymentUrlAndTransactionId()
+ {
+ // Arrange
+ var expectedUrl = "https://vnpay.vn/pay?txn=ABC789";
+ var expectedTxnId = "ABC789";
+
+ _vnpayGatewayMock.Setup(g => g.CreatePaymentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PaymentResult(
+ Success: true,
+ TransactionId: expectedTxnId,
+ PaymentUrl: expectedUrl));
+
+ var command = new CreatePaymentCommand(
+ OrderId: Guid.NewGuid(),
+ Amount: 300_000m,
+ Currency: "VND",
+ GatewayName: "VNPay",
+ ReturnUrl: "https://goodgo.vn/payment/return",
+ IpAddress: "192.168.1.1");
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.PaymentUrl.Should().Be(expectedUrl);
+ result.Status.Should().Be("Processing");
+ }
+}
diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs
new file mode 100644
index 00000000..0b160e01
--- /dev/null
+++ b/services/wallet-service-net/tests/WalletService.UnitTests/Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs
@@ -0,0 +1,243 @@
+// EN: Unit tests for ProcessPaymentCallbackCommandHandler - payment callback (IPN) processing.
+// VI: Unit tests cho ProcessPaymentCallbackCommandHandler - xu ly callback (IPN) thanh toan.
+
+using FluentAssertions;
+using Moq;
+using WalletService.API.Application.Commands.Payments;
+using WalletService.Domain.AggregatesModel.PaymentAggregate;
+using WalletService.Domain.Exceptions;
+using WalletService.Domain.SeedWork;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace WalletService.UnitTests.Application.Commands;
+
+///
+/// EN: Unit tests for ProcessPaymentCallbackCommandHandler.
+/// VI: Unit tests cho ProcessPaymentCallbackCommandHandler.
+///
+public class ProcessPaymentCallbackCommandHandlerTests
+{
+ private readonly Mock _paymentRepositoryMock;
+ private readonly Mock _vnpayGatewayMock;
+ private readonly Mock _unitOfWorkMock;
+ private readonly Mock> _loggerMock;
+ private readonly ProcessPaymentCallbackCommandHandler _handler;
+
+ public ProcessPaymentCallbackCommandHandlerTests()
+ {
+ _paymentRepositoryMock = new Mock();
+ _vnpayGatewayMock = new Mock();
+ _unitOfWorkMock = new Mock();
+ _loggerMock = new Mock>();
+
+ _vnpayGatewayMock.Setup(g => g.GatewayName).Returns("VNPay");
+
+ _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny()))
+ .ReturnsAsync(true);
+ _paymentRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
+
+ var gateways = new List { _vnpayGatewayMock.Object };
+
+ _handler = new ProcessPaymentCallbackCommandHandler(
+ _paymentRepositoryMock.Object,
+ gateways,
+ _loggerMock.Object);
+ }
+
+ ///
+ /// EN: Helper to create a Payment entity in Processing state for callback testing.
+ /// VI: Helper de tao Payment entity o trang thai Processing de test callback.
+ ///
+ private static Payment CreateProcessingPayment(Guid orderId)
+ {
+ var payment = new Payment(orderId, 100_000m, "VND", "VNPay");
+ payment.MarkAsProcessing("https://vnpay.vn/pay", "TXN_ORIGINAL");
+ return payment;
+ }
+
+ [Fact]
+ public async Task Handle_WithValidCallback_ShouldUpdatePaymentStatusToCompleted()
+ {
+ // Arrange
+ var orderId = Guid.NewGuid();
+ var payment = CreateProcessingPayment(orderId);
+
+ _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny()))
+ .Returns(true);
+ _paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
+ .ReturnsAsync(payment);
+
+ var parameters = new Dictionary
+ {
+ { "vnp_SecureHash", "valid_hash_123" },
+ { "vnp_TxnRef", orderId.ToString() },
+ { "vnp_ResponseCode", "00" },
+ { "vnp_TransactionNo", "VNP_TXN_001" }
+ };
+
+ var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Success.Should().BeTrue();
+ result.PaymentId.Should().Be(payment.Id);
+ result.OrderId.Should().Be(orderId);
+ result.Status.Should().Be("Completed");
+ result.ErrorMessage.Should().BeNull();
+
+ _paymentRepositoryMock.Verify(r => r.Update(payment), Times.Once);
+ _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidSignature_ShouldRejectCallback()
+ {
+ // Arrange
+ _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny()))
+ .Returns(false);
+
+ var parameters = new Dictionary
+ {
+ { "vnp_SecureHash", "invalid_hash" },
+ { "vnp_TxnRef", Guid.NewGuid().ToString() },
+ { "vnp_ResponseCode", "00" }
+ };
+
+ var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Invalid signature should be rejected without updating any payment.
+ // VI: Chu ky khong hop le nen bi tu choi ma khong cap nhat payment nao.
+ result.Should().NotBeNull();
+ result.Success.Should().BeFalse();
+ result.Status.Should().Be("InvalidSignature");
+ result.ErrorMessage.Should().Contain("Invalid callback signature");
+
+ _paymentRepositoryMock.Verify(r => r.GetByOrderIdAsync(It.IsAny()), Times.Never);
+ _paymentRepositoryMock.Verify(r => r.Update(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_WithNonExistentPayment_ShouldReturnNotFound()
+ {
+ // Arrange
+ var orderId = Guid.NewGuid();
+
+ _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny()))
+ .Returns(true);
+ _paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
+ .ReturnsAsync((Payment?)null);
+
+ var parameters = new Dictionary
+ {
+ { "vnp_SecureHash", "valid_hash" },
+ { "vnp_TxnRef", orderId.ToString() },
+ { "vnp_ResponseCode", "00" },
+ { "vnp_TransactionNo", "VNP_TXN_002" }
+ };
+
+ var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Success.Should().BeFalse();
+ result.PaymentId.Should().BeNull();
+ result.OrderId.Should().Be(orderId);
+ result.Status.Should().Be("NotFound");
+ result.ErrorMessage.Should().Contain("Payment not found");
+ }
+
+ [Fact]
+ public async Task Handle_WithFailedResponseCode_ShouldUpdatePaymentToFailed()
+ {
+ // Arrange
+ var orderId = Guid.NewGuid();
+ var payment = CreateProcessingPayment(orderId);
+
+ _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny()))
+ .Returns(true);
+ _paymentRepositoryMock.Setup(r => r.GetByOrderIdAsync(orderId))
+ .ReturnsAsync(payment);
+
+ var parameters = new Dictionary
+ {
+ { "vnp_SecureHash", "valid_hash" },
+ { "vnp_TxnRef", orderId.ToString() },
+ { "vnp_ResponseCode", "24" },
+ { "vnp_TransactionNo", "" },
+ { "vnp_OrderInfo", "Customer cancelled payment" }
+ };
+
+ var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // EN: Failed response code should mark payment as failed.
+ // VI: Ma phan hoi that bai nen danh dau payment la that bai.
+ result.Should().NotBeNull();
+ result.Success.Should().BeFalse();
+ result.Status.Should().Be("Failed");
+ result.ErrorMessage.Should().Contain("24");
+
+ _paymentRepositoryMock.Verify(r => r.Update(payment), Times.Once);
+ _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidTransactionReference_ShouldReturnInvalidReference()
+ {
+ // Arrange
+ _vnpayGatewayMock.Setup(g => g.ValidateCallback(It.IsAny>(), It.IsAny()))
+ .Returns(true);
+
+ var parameters = new Dictionary
+ {
+ { "vnp_SecureHash", "valid_hash" },
+ { "vnp_TxnRef", "not-a-valid-guid" },
+ { "vnp_ResponseCode", "00" }
+ };
+
+ var command = new ProcessPaymentCallbackCommand("VNPay", parameters);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Success.Should().BeFalse();
+ result.Status.Should().Be("InvalidReference");
+ result.ErrorMessage.Should().Contain("Invalid transaction reference");
+ }
+
+ [Fact]
+ public async Task Handle_WithUnsupportedGateway_ShouldThrowWalletDomainException()
+ {
+ // Arrange
+ var parameters = new Dictionary
+ {
+ { "vnp_SecureHash", "hash" },
+ { "vnp_TxnRef", Guid.NewGuid().ToString() }
+ };
+
+ var command = new ProcessPaymentCallbackCommand("MoMo", parameters);
+
+ // Act
+ var action = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await action.Should().ThrowAsync()
+ .WithMessage("*MoMo*not supported*");
+ }
+}