# Order Service - Service Documentation

> Auto-generated from source code audit. Last updated: 2026-03-13.

## Overview

**Order Service** is the central order processing microservice for the GoodGo POS platform. It orchestrates order creation, payment, fulfillment, returns, exchanges, and reporting across multiple business verticals (Restaurant/F&B, Retail, Spa/Services).

- **Framework**: .NET 10.0, C# 14
- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1)
- **Database**: PostgreSQL (Neon) via EF Core 10 (writes) + Dapper 2.1 (reads)
- **Real-time**: SignalR with Redis backplane + MessagePack protocol
- **Port**: 5017 (Development)
- **Base Route**: `api/v1/orders`, `api/v1/admin/orders`, `api/v1/reports`
- **SignalR Hub**: `/hubs/pos`
- **Health Checks**: `/health`, `/health/live`, `/health/ready`

### Key Features
- Multi-vertical order processing via Strategy pattern (Physical, PreparedFood, Service)
- Multi-payment support (cash, card, VNPay, Momo, QR, bank transfer)
- Real-time POS/KDS notifications via SignalR
- Multi-tenant row-level security (EF Core global query filters + PostgreSQL RLS)
- Return and exchange workflows
- Revenue analytics, staff performance, and End-of-Day reports
- Idempotency support for duplicate request detection

---

## API Endpoints

### OrdersController (`api/v1/orders`)

| Method | Route | Description | Request | Response |
|--------|-------|-------------|---------|----------|
| POST | `/api/v1/orders` | Create a new order | `CreateOrderCommand` (body) | `201 Created` - `CreateOrderResult` |
| GET | `/api/v1/orders/{id}` | Get order by ID | `id` (path), `shopId` (query) | `200 OK` - `OrderDto` / `404` |
| GET | `/api/v1/orders` | List orders by shop (paginated) | `shopId`, `status?`, `fromDate?`, `toDate?`, `page`, `pageSize` (query) | `200 OK` - `PagedResult<OrderSummaryDto>` |
| POST | `/api/v1/orders/{id}/pay` | Process payment | `id` (path), `shopId` (query), `PayOrderRequest` (body) | `200 OK` - `PayOrderResult` / `400` |
| POST | `/api/v1/orders/{id}/payment-callback` | Payment gateway callback | `id` (path), `PaymentCallbackRequest` (body) | `200 OK` - `CompleteOrderPaymentResult` / `400` |
| POST | `/api/v1/orders/{id}/cancel` | Cancel an order | `id` (path), `shopId` (query), `CancelOrderRequest` (body) | `200 OK` - `CancelOrderResult` |
| POST | `/api/v1/orders/{id}/complete` | Complete an order | `id` (path), `shopId` (query) | `200 OK` - `CompleteOrderResult` |
| GET | `/api/v1/orders/dashboard` | POS dashboard stats | `shopId`, `period?` ("today"/"7d"/"30d") (query) | `200 OK` - `PosDashboardDto` |
| GET | `/api/v1/orders/active-by-table` | Active orders grouped by table | `shopId` (query) | `200 OK` - `List<ActiveTableOrderDto>` |
| POST | `/api/v1/orders/returns` | Create a return | `CreateReturnCommand` (body) | `201 Created` - `CreateReturnResult` / `400` |
| POST | `/api/v1/orders/exchanges` | Create an exchange | `CreateExchangeCommand` (body) | `201 Created` - `CreateExchangeResult` / `400` |
| GET | `/api/v1/orders/{orderId}/returns` | Get return history | `orderId` (path) | `200 OK` - `List<ReturnOrderDto>` |
| GET | `/api/v1/orders/customer/{customerId}` | Get orders by customer | `customerId` (path), `page`, `pageSize` (query) | `200 OK` - `PagedResult<OrderSummaryDto>` |

### AdminOrdersController (`api/v1/admin/orders`)

| Method | Route | Description | Request | Response |
|--------|-------|-------------|---------|----------|
| GET | `/api/v1/admin/orders` | List all orders (admin) | `shopId?`, `customerId?`, `status?`, `fromDate?`, `toDate?`, `minAmount?`, `maxAmount?`, `page`, `pageSize` (query) | `200 OK` - `PagedResult<OrderSummaryDto>` |
| GET | `/api/v1/admin/orders/stats` | Order statistics | `shopId?`, `fromDate?`, `toDate?` (query) | `200 OK` - `OrderStatsDto` |
| GET | `/api/v1/admin/orders/export` | Export orders as CSV | `shopId?`, `fromDate?`, `toDate?` (query) | `200 OK` - CSV file download |

### ReportsController (`api/v1/reports`)

| Method | Route | Description | Request | Response |
|--------|-------|-------------|---------|----------|
| GET | `/api/v1/reports/revenue` | Revenue report (daily/weekly/monthly) | `shopId`, `period`, `fromDate?`, `toDate?` (query) | `200 OK` - `RevenueReportDto` |
| GET | `/api/v1/reports/top-products` | Top selling products | `shopId`, `limit?`, `fromDate?`, `toDate?` (query) | `200 OK` - `List<TopProductDto>` |
| GET | `/api/v1/reports/revenue-analytics` | Advanced revenue analytics | `shopId`, `startDate`, `endDate`, `period?` (query) | `200 OK` - `RevenueAnalyticsDto` |
| GET | `/api/v1/reports/staff-performance` | Staff performance metrics | `shopId`, `startDate`, `endDate` (query) | `200 OK` - `StaffPerformanceDto` |
| GET | `/api/v1/reports/eod` | End-of-Day report | `shopId`, `date?` (query) | `200 OK` - `EodReportDto` |
| POST | `/api/v1/reports/close-day` | Close business day | `CloseDayRequest` (body) | `200 OK` - `CloseDayResult` / `400` |

### SignalR Hub (`/hubs/pos`)

| Method | Direction | Description |
|--------|-----------|-------------|
| `JoinShop(shopId)` | Client -> Server | Join shop group for all POS updates |
| `JoinKds(shopId)` | Client -> Server | Join KDS group for kitchen updates |
| `JoinPos(shopId)` | Client -> Server | Join POS terminal group |
| `LeaveShop(shopId)` | Client -> Server | Leave shop group |
| `LeaveKds(shopId)` | Client -> Server | Leave KDS group |
| `LeavePos(shopId)` | Client -> Server | Leave POS terminal group |
| `OrderCreated` | Server -> Client | New order created notification |
| `OrderUpdated` | Server -> Client | Order updated notification |
| `OrderStatusChanged` | Server -> Client | Order status change notification |
| `KitchenTicketCreated` | Server -> Client | New kitchen ticket (to KDS) |
| `KitchenTicketUpdated` | Server -> Client | Kitchen ticket update (to KDS) |
| `PaymentCompleted` | Server -> Client | Payment completed notification |
| `TableStatusChanged` | Server -> Client | Table status change notification |

**Groups**: `shop:{shopId}`, `kds:{shopId}`, `pos:{shopId}`
**Auth**: JWT required (token via query string `access_token` for WebSocket)
**Shop Access**: Validated via `shop_id` JWT claim (prevents cross-tenant access)

---

## Commands

### CreateOrderCommand
- **Input**: `ShopId` (Guid), `CustomerId?` (Guid?), `Items` (List\<OrderItemRequest\>), `DiscountAmount?`, `DiscountType?`, `DiscountReference?`, `TableId?`
- **OrderItemRequest**: `ProductId`, `ProductName`, `ProductType` ("Physical"|"Service"|"PreparedFood"), `Quantity`, `UnitPrice`, `TrackInventory`
- **Logic**: Creates Order aggregate -> adds items -> validates each item via strategy (RetailStrategy/ServiceStrategy/FnbStrategy) -> executes each item via strategy (inventory deduction / kitchen ticket / booking) -> applies discount -> marks as Validated -> saves -> sends SignalR notification
- **Result**: `CreateOrderResult(OrderId, TotalAmount, Status)`
- **Validator**: ShopId required, Items non-empty, each item validated (ProductId, ProductName, ProductType in ["Physical","Service","PreparedFood"], Quantity > 0, UnitPrice >= 0)

### PayOrderCommand
- **Input**: `OrderId`, `ShopId`, `PaymentMethod` ("cash"|"card"|"vnpay"|"momo"|"qr"|"transfer"), `AmountTendered?`, `ReturnUrl?`, `IpAddress?`
- **Logic**: Loads order -> verifies shop ownership -> routes to payment flow:
  - **Cash**: Validates sufficient amount -> generates CASH-* transaction ID -> calculates change -> marks Paid+Processing
  - **Card/QR/Transfer**: Generates CARD-* transaction ID -> marks Paid+Processing (POS terminal confirmed)
  - **VNPay/Momo**: Calls wallet-service CreatePaymentAsync -> marks PaymentPending -> returns payment URL for redirect
- **Result**: `PayOrderResult(Success, Status, PaymentUrl?, ChangeAmount?, TransactionId?, ErrorMessage?)`
- **Validator**: OrderId/ShopId required, PaymentMethod in supported list, AmountTendered required for cash, ReturnUrl required for vnpay/momo

### CompleteOrderPaymentCommand
- **Input**: `OrderId`, `GatewayTransactionId`, `IsSuccess`, `GatewayResponseCode?`
- **Logic**: Called by wallet-service callback after online payment. If success -> CompletePayment + MarkAsProcessing. If failure -> Cancel order.
- **Result**: `CompleteOrderPaymentResult(Success, Status, ErrorMessage?)`
- **Validator**: OrderId required, GatewayTransactionId required (max 200), GatewayResponseCode max 50

### CancelOrderCommand
- **Input**: `OrderId`, `ShopId`, `Reason`
- **Logic**: Loads order -> verifies shop ownership -> calls Order.Cancel(reason) (domain validates not Completed/already Cancelled) -> saves -> sends SignalR notification
- **Result**: `CancelOrderResult(Success, Status)`
- **Validator**: OrderId/ShopId required, Reason required (max 500)

### CompleteOrderCommand
- **Input**: `OrderId`, `ShopId`
- **Logic**: Loads order -> verifies shop ownership -> calls Order.MarkAsCompleted() (domain validates must be Processing) -> saves -> sends SignalR notification
- **Result**: `CompleteOrderResult(Success, Status)`
- **Validator**: OrderId/ShopId required

### CreateReturnCommand
- **Input**: `ShopId`, `OriginalOrderId`, `Items` (List\<ReturnItemDto\>), `Reason`
- **ReturnItemDto**: `OrderItemId`, `Quantity`, `Reason?`
- **Logic**: Validates original order (must be Completed/Paid, same shop) -> creates new return order with items from original -> marks Validated -> ProcessReturn (sets isReturn, returnReason, originalOrderId) -> saves
- **Result**: `CreateReturnResult(Success, ReturnOrderId?, RefundAmount?, ErrorMessage?)`
- **Validator**: ShopId/OriginalOrderId required, Reason required (max 1000), Items non-empty, each item: OrderItemId required, Quantity > 0

### CreateExchangeCommand
- **Input**: `ShopId`, `OriginalOrderId`, `ReturnItems` (List\<ReturnItemDto\>), `NewItems` (List\<ExchangeItemDto\>), `Reason`
- **ExchangeItemDto**: `ProductId`, `Quantity`, `UnitPrice`
- **Logic**: Validates original order -> creates return order (step 1) -> creates new order with replacement items (step 2) -> calculates price difference -> saves both in same transaction -> raises OrderExchangedDomainEvent
- **Result**: `CreateExchangeResult(Success, ReturnOrderId?, NewOrderId?, PriceDifference?, ErrorMessage?)`
- **Validator**: ShopId/OriginalOrderId required, Reason required, ReturnItems/NewItems non-empty with item-level validation

### CloseDayCommand
- **Input**: `ShopId`, `CloseDate`
- **Logic**: Checks for pending/in-progress orders (status 1,2,3,4,7) via Dapper -> generates EOD report via GetEodReportQuery -> returns report with warning if pending orders exist
- **Result**: `CloseDayResult(Success, Report?, Message?, PendingOrderCount)`
- **Validator**: ShopId required, CloseDate required and not in future

---

## Queries

### GetOrderByIdQuery
- **Input**: `OrderId`, `ShopId`
- **Logic**: Dapper query joining `orders` + `order_statuses` + `order_items`, filtered by OrderId and ShopId
- **Result**: `OrderDto` (with items) or null

### ListOrdersByShopQuery
- **Input**: `ShopId`, `Status?`, `FromDate?`, `ToDate?`, `Page`, `PageSize`
- **Logic**: Dapper query with dynamic WHERE clause, paginated (LIMIT/OFFSET), ordered by created_at DESC
- **Result**: `PagedResult<OrderSummaryDto>`

### AdminListOrdersQuery
- **Input**: `ShopId?`, `CustomerId?`, `Status?`, `FromDate?`, `ToDate?`, `MinAmount?`, `MaxAmount?`, `Page`, `PageSize`
- **Logic**: Dapper query with all optional filters, no shop restriction (admin access)
- **Result**: `PagedResult<OrderSummaryDto>`

### GetOrdersByCustomerQuery
- **Input**: `CustomerId`, `Page`, `PageSize`
- **Logic**: Dapper query filtered by customer_id, paginated
- **Result**: `PagedResult<OrderSummaryDto>`

### GetActiveTableOrdersQuery
- **Input**: `ShopId`
- **Logic**: Gets orders with status_id=2 (Validated) that have table_id, then batch-loads items via ANY(@OrderIds)
- **Result**: `List<ActiveTableOrderDto>` (each with items)

### GetOrderReturnsQuery
- **Input**: `OrderId`
- **Logic**: Uses EF Core repository `GetReturnsByOriginalOrderIdAsync` to find return orders
- **Result**: `List<ReturnOrderDto>`

### GetOrderStatsQuery
- **Input**: `ShopId?`, `FromDate?`, `ToDate?`
- **Logic**: Dapper aggregate (COUNT, SUM, AVG) + GROUP BY status name
- **Result**: `OrderStatsDto(TotalOrders, TotalRevenue, AverageOrderValue, OrdersByStatus)`

### ExportOrdersQuery
- **Input**: `ShopId?`, `FromDate?`, `ToDate?`
- **Logic**: Dapper query all matching orders -> generates CSV string
- **Result**: `ExportOrdersResult(FileName, CsvContent)` (served as file download)

### GetPosDashboardQuery
- **Input**: `ShopId`, `Period?` ("today"|"7d"|"30d")
- **Logic**: Multiple Dapper queries: aggregate stats, top 5 products, payment breakdown by status, hourly revenue, last 10 orders
- **Result**: `PosDashboardDto(Revenue, OrderCount, ItemsSold, AvgOrderValue, PopularItems, PaymentBreakdown, HourlyRevenue, RecentOrders)`

### GetRevenueReportQuery
- **Input**: `Period` ("daily"|"weekly"|"monthly"), `ShopId`, `FromDate?`, `ToDate?`
- **Logic**: Dapper with DATE_TRUNC grouping
- **Result**: `RevenueReportDto(Period, ShopId, TotalRevenue, TotalOrders, Data[])`

### GetTopProductsQuery
- **Input**: `ShopId`, `Limit`, `FromDate?`, `ToDate?`
- **Logic**: Dapper GROUP BY product_id, product_name, ORDER BY quantity DESC
- **Result**: `List<TopProductDto>`

### GetRevenueAnalyticsQuery
- **Input**: `ShopId`, `StartDate`, `EndDate`, `Period`
- **Logic**: 6 Dapper queries: current aggregate, previous period revenue, trends, payment method breakdown, top 10 products, vertical breakdown. Calculates growth percentage.
- **Result**: `RevenueAnalyticsDto(TotalRevenue, PreviousPeriodRevenue, GrowthPercentage, TotalOrders, AverageOrderValue, Trends, PaymentMethods, TopProducts, VerticalBreakdown)`
- **Validator**: ShopId required, StartDate < EndDate, EndDate not future, Period in ["daily","weekly","monthly"]

### GetStaffPerformanceQuery
- **Input**: `ShopId`, `StartDate`, `EndDate`
- **Logic**: Dapper GROUP BY staff_id, staff_name with FILTER for completed/cancelled, calculates completion rate and avg handling time
- **Result**: `StaffPerformanceDto(Staff[], ShopAverage)`
- **Validator**: ShopId required, StartDate < EndDate, EndDate not future

### GetEodReportQuery
- **Input**: `ShopId`, `ReportDate`
- **Logic**: 4 Dapper queries: aggregate (total/completed/cancelled/revenue by payment type/discounts), payment breakdown, top 10 items, hourly revenue
- **Result**: `EodReportDto(ReportDate, ShopId, TotalOrders, CompletedOrders, CancelledOrders, TotalRevenue, CashRevenue, CardRevenue, OnlineRevenue, DiscountTotal, PaymentBreakdown, TopItems, HourlyRevenue)`
- **Validator**: ShopId required, ReportDate required and not future

---

## Domain Model

### Order (Aggregate Root)

**Table**: `orders`

**Private Fields / Properties**:
| Field | Type | Description |
|-------|------|-------------|
| `_shopId` / `ShopId` | Guid | Shop that owns the order |
| `_customerId` / `CustomerId` | Guid? | Customer (null for walk-in) |
| `_tableId` / `TableId` | Guid? | Table for dine-in orders |
| `StatusId` | int | FK to order_statuses |
| `_status` / `Status` | OrderStatus | Resolved from StatusId |
| `_totalAmount` / `TotalAmount` | decimal | Calculated: sum(items) - discount |
| `_discountAmount` / `DiscountAmount` | decimal | Discount applied |
| `_discountType` / `DiscountType` | string? | Discount type (e.g. "percentage", "fixed") |
| `_discountReference` / `DiscountReference` | string? | Promotion/coupon reference |
| `_paymentMethod` / `PaymentMethod` | string? | "cash", "card", "vnpay", "momo", "qr", "transfer" |
| `_transactionId` / `TransactionId` | string? | External transaction ID |
| `_amountTendered` / `AmountTendered` | decimal? | Amount customer paid (cash) |
| `_changeAmount` / `ChangeAmount` | decimal? | Change returned (cash) |
| `_isReturn` / `IsReturn` | bool | Whether this is a return order |
| `_returnReason` / `ReturnReason` | string? | Reason for return |
| `_returnedAt` / `ReturnedAt` | DateTime? | When return was processed |
| `_originalOrderId` / `OriginalOrderId` | Guid? | Original order (for returns/exchanges) |
| `_createdAt` / `CreatedAt` | DateTime | Creation timestamp (UTC) |
| `_updatedAt` / `UpdatedAt` | DateTime? | Last update timestamp (UTC) |
| `_items` / `Items` | List\<OrderItem\> | Line items (owned collection) |

**Behavior Methods**:
| Method | Transitions | Domain Event | Validation |
|--------|-------------|--------------|------------|
| `Order(shopId, customerId?, tableId?)` | -> Draft | `OrderCreatedDomainEvent` | ShopId != empty |
| `AddItem(item)` | (Draft only) | - | Status must be Draft |
| `MarkAsValidated()` | Draft -> Validated | - | Must be Draft, must have items |
| `MarkAsPaid(method, txnId, amountTendered?)` | Validated/PaymentPending -> Paid | `OrderPaidDomainEvent` | Must be Validated or PaymentPending |
| `MarkAsPaymentPending(method, txnId)` | Validated -> PaymentPending | `OrderPaymentPendingDomainEvent` | Must be Validated |
| `CompletePayment(gatewayTxnId)` | PaymentPending -> Paid | `OrderPaidDomainEvent` | Must be PaymentPending |
| `MarkAsProcessing()` | Paid -> Processing | - | Must be Paid |
| `MarkAsCompleted()` | Processing -> Completed | `OrderCompletedDomainEvent` | Must be Processing |
| `Cancel(reason)` | Any (except Completed/Cancelled) -> Cancelled | `OrderCancelledDomainEvent` | Not Completed, not already Cancelled |
| `ProcessReturn(reason, originalOrderId?)` | -> Returned | `OrderReturnedDomainEvent` | Reason required |
| `ApplyDiscount(amount, type?, reference?)` | - | - | Amount >= 0 |

### OrderItem (Entity, Owned by Order)

**Table**: `order_items`

| Field | Type | Description |
|-------|------|-------------|
| `Id` | Guid | Primary key |
| `_productId` / `ProductId` | Guid | Product from Catalog Service |
| `_productName` / `ProductName` | string | Snapshot of product name |
| `_productType` / `ProductType` | string | "Physical", "Service", "PreparedFood" |
| `_quantity` / `Quantity` | int | Quantity ordered |
| `_unitPrice` / `UnitPrice` | decimal | Unit price at order time |
| `TotalPrice` | decimal | Computed: Quantity * UnitPrice |
| `_status` / `Status` | string | "Pending", "Completed", "Failed" |
| `_trackInventory` / `TrackInventory` | bool | Auto-deduct inventory flag (default true) |
| `_metadata` / `Metadata` | string? | JSON metadata (e.g. appointment details) |

**Methods**: `MarkAsCompleted()`, `MarkAsFailed()`

### OrderStatus (Enumeration)

| Id | Name | Description |
|----|------|-------------|
| 1 | Draft | Created but not validated |
| 2 | Validated | All items validated and available |
| 3 | Paid | Payment processed successfully |
| 4 | Processing | Being fulfilled |
| 5 | Completed | Successfully completed |
| 6 | Cancelled | Cancelled |
| 7 | PaymentPending | Waiting for online payment gateway |
| 8 | Returned | Items returned (defined in code, not seeded) |

### Domain Events

| Event | Raised By | Payload |
|-------|-----------|---------|
| `OrderCreatedDomainEvent` | Order constructor | `Order` |
| `OrderPaidDomainEvent` | `MarkAsPaid`, `CompletePayment` | `Order` |
| `OrderCompletedDomainEvent` | `MarkAsCompleted` | `Order` |
| `OrderPaymentPendingDomainEvent` | `MarkAsPaymentPending` | `Order` |
| `OrderCancelledDomainEvent` | `Cancel` | `Order`, `Reason` |
| `OrderReturnedDomainEvent` | `ProcessReturn` | `Order` |
| `OrderExchangedDomainEvent` | CreateExchangeCommandHandler | `ReturnOrder`, `NewOrder` |

---

## Database Schema

### Table: `orders`

| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | uuid | NOT NULL (PK) | Order ID (generated in code) |
| `shop_id` | uuid | NOT NULL | Shop tenant ID |
| `customer_id` | uuid | nullable | Customer ID |
| `table_id` | uuid | nullable | Table ID (dine-in) |
| `status_id` | int | NOT NULL | FK to order_statuses |
| `total_amount` | decimal(18,2) | NOT NULL | Order total |
| `notes` | varchar(2000) | nullable | Notes |
| `discount_amount` | decimal(18,2) | NOT NULL | Discount amount |
| `discount_type` | varchar(50) | nullable | Discount type |
| `discount_reference` | varchar(255) | nullable | Promotion/coupon ref |
| `payment_method` | varchar(50) | nullable | Payment method used |
| `transaction_id` | varchar(255) | nullable | External transaction ID |
| `amount_tendered` | decimal(18,2) | nullable | Amount customer paid |
| `change_amount` | decimal(18,2) | nullable | Change returned |
| `is_return` | boolean | NOT NULL (default false) | Return order flag |
| `return_reason` | varchar(1000) | nullable | Return reason |
| `returned_at` | timestamp with time zone | nullable | Return timestamp |
| `original_order_id` | uuid | nullable | Original order (returns) |
| `created_at` | timestamp with time zone | NOT NULL | Creation time |
| `updated_at` | timestamp with time zone | nullable | Last update time |

**Indexes**:
- `ix_orders_shop_id` on `shop_id`
- `ix_orders_customer_id` on `customer_id`
- `ix_orders_status_id` on `status_id`
- `ix_orders_created_at` on `created_at`
- `ix_orders_original_order_id` on `original_order_id`

**Note**: Dapper queries reference `o.staff_id`, `o.staff_name`, `o.completed_at`, `o.vertical` columns that are NOT defined in the EF entity configuration. These queries will fail at runtime unless the columns exist in the database via manual migration.

### Table: `order_items` (Owned by Order)

| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | uuid | NOT NULL (PK) | Item ID |
| `order_id` | uuid | NOT NULL (FK) | Parent order |
| `product_id` | uuid | NOT NULL | Product from catalog |
| `product_name` | varchar(255) | NOT NULL | Product name snapshot |
| `product_type` | varchar(50) | NOT NULL | "Physical"/"Service"/"PreparedFood" |
| `quantity` | int | NOT NULL | Quantity |
| `unit_price` | decimal(18,2) | NOT NULL | Unit price |
| `status` | varchar(50) | NOT NULL | "Pending"/"Completed"/"Failed" |
| `track_inventory` | boolean | NOT NULL (default true) | Auto inventory deduction |
| `metadata` | jsonb | nullable | Additional JSON data |

### Table: `order_statuses` (Lookup/Seed)

| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | int | NOT NULL (PK) | Status ID |
| `name` | varchar(50) | NOT NULL | Status name |

**Seeded data**: Draft(1), Validated(2), Paid(3), Processing(4), Completed(5), Cancelled(6), PaymentPending(7)

**Note**: Returned(8) is defined in code but NOT included in seed data.

### Migrations

| Migration | Date | Description |
|-----------|------|-------------|
| `20260117175742_InitialOrder` | 2026-01-17 | Initial schema: orders, order_items, order_statuses (1-6) |
| `20260305004928_AddTableIdAndDiscountFields` | 2026-03-05 | Added table_id, discount fields |
| `20260306175520_PhaseTwo` | 2026-03-06 | Added payment fields, return fields, track_inventory, PaymentPending(7) status, original_order_id index |

---

## Strategy Pattern (Line Item Processing)

| Strategy | SupportedType | Validate | Execute |
|----------|---------------|----------|---------|
| `RetailStrategy` | "Physical" | Checks inventory availability via InventoryServiceClient | Deducts stock via InventoryServiceClient |
| `FnbStrategy` | "PreparedFood" | Always returns true (no pre-check) | Creates kitchen ticket via FnbEngineClient + deducts recipe ingredients via InventoryServiceClient |
| `ServiceStrategy` | "Service" | Checks availability via BookingServiceClient (parses metadata for StartTime/DurationMinutes) | Creates appointment via BookingServiceClient |

**Interface**: `ILineItemStrategy` (Domain layer) with `SupportedType`, `ValidateAsync`, `ExecuteAsync`
**Factory**: `StrategyFactory` resolves strategy by product type (case-insensitive)

---

## Multi-Tenant Security

### EF Core Global Query Filter
- `Order` entity filtered by `_shopId == tenantProvider.GetCurrentShopId()`
- Bypassed when: `_tenantProvider` is null, `ShouldBypassTenantFilter()` returns true, or `GetCurrentShopId()` returns null

### PostgreSQL RLS (Defense-in-Depth)
- `TenantMiddleware` sets `SET LOCAL app.current_shop_id` and `app.current_merchant_id` session variables
- Runs after authentication middleware
- Skipped for service-to-service calls (`X-Service-Call: internal`) and admin users

### Tenant Provider
- `HttpContextTenantProvider` extracts tenant context from:
  1. JWT claim `shop_id`
  2. HTTP header `X-Shop-Id` (POS fallback)
- Admin detection via `role` claims: "admin", "system", "superadmin"
- `OrderTenantProviderAdapter` bridges `ITenantProvider` (API) to `IOrderTenantProvider` (Infrastructure)

---

## Dependencies (Cross-Service Communication)

### HTTP Clients (with Polly retry + circuit breaker)

| Client | Target Service | Default URL | Timeout | Usage |
|--------|---------------|-------------|---------|-------|
| `CatalogServiceClient` | Catalog Service | `http://catalog-service-net:8080` | 10s | Get product details |
| `InventoryServiceClient` | Inventory Service | `http://inventory-service-net:8080` | 10s | Check/deduct stock, deduct by ID |
| `FnbEngineClient` | F&B Engine | `http://fnb-engine-net:8080` | 10s | Kitchen tickets, recipe lookup |
| `BookingServiceClient` | Booking Service | `http://booking-service-net:8080` | 10s | Availability check, create appointment |
| `WalletServiceClient` | Wallet Service | `http://wallet-service-net:8080` | 15s | Create online payment (VNPay/Momo) |

**Polly Policies**: Retry 3x with exponential backoff (2^attempt seconds), Circuit breaker (5 failures, 30s break)

---

## MediatR Pipeline

```
Request -> LoggingBehavior -> ValidatorBehavior -> TransactionBehavior -> Handler
```

1. **LoggingBehavior**: Logs request name and elapsed time (Stopwatch)
2. **ValidatorBehavior**: Runs all registered FluentValidation validators, throws `ValidationException` on failure
3. **TransactionBehavior**: Auto-wraps Commands in DB transaction (skips Queries by name convention `*Query`), uses `ExecutionStrategy` for retry-safe transactions

---

## Configuration

### appsettings.json

| Key | Description | Default |
|-----|-------------|---------|
| `ConnectionStrings:DefaultConnection` | PostgreSQL connection string | Neon PostgreSQL |
| `Redis:ConnectionString` | Redis for SignalR backplane | `localhost:6379` |
| `SignalR:KeepAliveInterval` | SignalR keep-alive (seconds) | 15 |
| `SignalR:ClientTimeoutInterval` | Client timeout (seconds) | 30 |
| `SignalR:StatefulReconnectBufferSize` | Reconnect buffer size | 32768 |
| `SignalR:EnableMessagePack` | Enable MessagePack protocol | true |
| `Jwt:Authority` | JWT issuer authority | `http://localhost:5001` |
| `Jwt:Secret` | JWT secret key | (configured) |
| `AllowedOrigins` | CORS allowed origins | localhost:3000, 5173, 5000 |
| `Services:CatalogService` | Catalog service URL | `http://catalog-service-net:8080` |
| `Services:InventoryService` | Inventory service URL | `http://inventory-service-net:8080` |
| `Services:BookingService` | Booking service URL | `http://booking-service-net:8080` |
| `Services:FnbEngine` | F&B engine URL | `http://fnb-engine-net:8080` |
| `Services:WalletService` | Wallet service URL | `http://wallet-service-net:8080` |

### NuGet Dependencies (API)

| Package | Version |
|---------|---------|
| MediatR | 12.4.1 |
| FluentValidation | 11.11.0 |
| FluentValidation.DependencyInjectionExtensions | 11.11.0 |
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 |
| Swashbuckle.AspNetCore | 7.2.0 |
| AspNetCore.HealthChecks.NpgSql | 8.0.2 |
| AspNetCore.HealthChecks.Redis | 8.0.1 |
| Hellang.Middleware.ProblemDetails | 6.5.1 |
| Serilog.AspNetCore | 8.0.3 |
| Microsoft.AspNetCore.SignalR.StackExchangeRedis | 9.0.0 |
| Microsoft.AspNetCore.SignalR.Protocols.MessagePack | 9.0.0 |
| Microsoft.EntityFrameworkCore.Design | 10.0.0 |

---

## Tests

### Unit Tests (`tests/OrderService.UnitTests/`)

| Test Class | Tests |
|------------|-------|
| `OrderAggregateTests` | `CreateOrder_WithValidShopId_ShouldStartInDraftStatus`, `AddItem_InDraftStatus_ShouldRecalculateTotalAmount`, `MarkAsValidated_WithoutItems_ShouldThrowDomainException`, `FullLifecycle_DraftToCompleted_ShouldUpdateStatusSequentially`, `Cancel_CompletedOrder_ShouldThrowDomainException` |

### Functional Tests (`tests/OrderService.FunctionalTests/`)

| Test Class | Description |
|------------|-------------|
| `HealthChecksControllerTests` | Health endpoint tests |
| `OrdersControllerTests` | API endpoint integration tests |
| `CustomWebApplicationFactory` | Test server setup with InMemory DB |
| `TestAuthHandler` | Fake auth handler for tests |

---

## Known Issues / Gaps

1. **Missing DB columns**: Dapper queries in `GetStaffPerformanceQuery` reference `o.staff_id`, `o.staff_name`, `o.completed_at` columns, and `GetRevenueAnalyticsQuery` references `o.vertical` column. These are NOT defined in Order entity or EF configurations -- queries will fail at runtime unless columns exist via manual migration.

2. **Returned status not seeded**: `OrderStatus.Returned` (Id=8) is defined in code but NOT included in the `OrderStatusEntityTypeConfiguration` seed data. Return orders will fail to load if status is queried via JOIN to `order_statuses`.

3. **Exchange item names**: `CreateExchangeCommandHandler` hardcodes product name as "Exchange Item" and type as "Physical" for new items in exchanges -- these should be resolved from the catalog or passed by the frontend.

4. **No [Authorize] on controllers**: `OrdersController`, `AdminOrdersController`, and `ReportsController` do not have `[Authorize]` attributes. Authentication is configured in Program.cs but not enforced at controller level (only the SignalR hub has `[Authorize]`).

5. **Template remnants**: Build artifacts contain references to "MyService" namespace (from `_template_dot_net`), indicating the service was scaffolded from the template.
