# Meezi — Sprint Plan: POS Operations Polish + Hardening > **Copy-paste this entire file into Cursor as your implementation guide.** > Stack: ASP.NET Core 10 · Next.js 14 · PostgreSQL 16 · Redis · SignalR > Last updated: 2026-05-21 --- ## Overview Five self-contained PRs, ordered by dependency. Do them in sequence. | PR | Title | Effort | |----|-------|--------| | PR-1 | Void Order Line | ~2h | | PR-2 | Transfer Table | ~2h | | PR-3 | SignalR Table Board Realtime | ~3h | | PR-4 | Receipt Print Preview | ~2h | | PR-5 | Integration Test Coverage | ~2h | | PR-6 | Fix NU1903 Package Warnings | ~1h | --- ## PR-1 — Void Order Line ### Goal Allow staff to void (cancel) an individual line item on an open order. Only `Manager` role can void. ### Files to create / edit ``` src/Meezi.Core/Entities/OrderItem.cs ← add IsVoided, VoidedAt, VoidedByUserId src/Meezi.Core/Entities/Order.cs ← update TotalAmount computed property src/Meezi.Infrastructure/Data/Migrations/ ← new migration: VoidOrderLine src/Meezi.API/Services/OrderService.cs ← add VoidOrderItemAsync src/Meezi.API/Controllers/OrdersController.cs ← add PATCH endpoint src/Meezi.API/DTOs/OrderItemDto.cs ← add IsVoided web/dashboard/src/components/pos/pos-screen.tsx ← void button per line web/dashboard/src/lib/api/orders.ts ← voidOrderItem() messages/fa.json, en.json, ar.json ← void strings ``` --- ### Step 1 — Domain: `OrderItem.cs` Add to the existing `OrderItem` entity: ```csharp // src/Meezi.Core/Entities/OrderItem.cs public bool IsVoided { get; set; } = false; public DateTime? VoidedAt { get; set; } public Guid? VoidedByUserId { get; set; } ``` --- ### Step 2 — Domain: `Order.cs` Update `TotalAmount` (or wherever line totals are summed) to exclude voided items: ```csharp // In Order.cs — wherever TotalAmount is computed public decimal TotalAmount => Items .Where(i => !i.IsVoided) .Sum(i => i.UnitPrice * i.Quantity); ``` If `TotalAmount` is a stored column rather than computed, update `OrderService` instead (see Step 4). --- ### Step 3 — EF Migration ```bash cd src/Meezi.Infrastructure dotnet ef migrations add VoidOrderLine \ --startup-project ../../src/Meezi.API \ --output-dir Data/Migrations ``` Verify the generated migration adds: - `is_voided boolean not null default false` - `voided_at timestamp with time zone null` - `voided_by_user_id uuid null` --- ### Step 4 — Service: `OrderService.cs` Add this method: ```csharp // src/Meezi.API/Services/OrderService.cs public async Task> VoidOrderItemAsync( Guid orderId, Guid itemId, Guid voidedByUserId, CancellationToken ct = default) { var order = await _db.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct); if (order is null) return ApiResponse.Fail("ORDER_NOT_FOUND"); if (order.Status == OrderStatus.Closed) return ApiResponse.Fail("ORDER_ALREADY_CLOSED"); var item = order.Items.FirstOrDefault(i => i.Id == itemId); if (item is null) return ApiResponse.Fail("ITEM_NOT_FOUND"); if (item.IsVoided) return ApiResponse.Fail("ITEM_ALREADY_VOIDED"); item.IsVoided = true; item.VoidedAt = DateTime.UtcNow; item.VoidedByUserId = voidedByUserId; // Recompute stored total if not computed property order.TotalAmount = order.Items .Where(i => !i.IsVoided) .Sum(i => i.UnitPrice * i.Quantity); await _db.SaveChangesAsync(ct); return ApiResponse.Ok(_mapper.Map(order)); } ``` --- ### Step 5 — Controller: `OrdersController.cs` Add endpoint (inside the existing controller class): ```csharp // src/Meezi.API/Controllers/OrdersController.cs /// Void a single line item. Requires Manager role. [HttpPatch("{orderId}/items/{itemId}/void")] [Authorize(Roles = "Manager,Owner")] public async Task VoidOrderItem( Guid cafeId, Guid orderId, Guid itemId, CancellationToken ct) { var userId = User.GetUserId(); // existing helper var result = await _orderService.VoidOrderItemAsync(orderId, itemId, userId, ct); return result.IsSuccess ? Ok(result) : BadRequest(result); } ``` --- ### Step 6 — DTO: `OrderItemDto.cs` ```csharp public bool IsVoided { get; set; } public DateTime? VoidedAt { get; set; } ``` Add to AutoMapper profile if explicit mappings exist. --- ### Step 7 — Dashboard: API helper ```typescript // web/dashboard/src/lib/api/orders.ts export async function voidOrderItem( cafeId: string, orderId: string, itemId: string ): Promise { await apiClient.patch( `/cafes/${cafeId}/orders/${orderId}/items/${itemId}/void` ); } ``` --- ### Step 8 — Dashboard: UI in `pos-screen.tsx` Inside the line-item render loop, add a void button visible only to managers: ```tsx // web/dashboard/src/components/pos/pos-screen.tsx {isManager && !item.isVoided && ( )} {item.isVoided && ( {t("pos.voided")} )} ``` Handler: ```tsx const handleVoidItem = async (itemId: string) => { if (!confirm(t("pos.confirmVoid"))) return; await voidOrderItem(cafeId, activeOrderId, itemId); await reloadOrder(); // existing reload helper }; ``` --- ### Step 9 — i18n strings ```json // messages/fa.json "pos": { "void": "ابطال", "voided": "ابطال شده", "confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟" } // messages/en.json "pos": { "void": "Void", "voided": "Voided", "confirmVoid": "Are you sure you want to void this item?" } // messages/ar.json "pos": { "void": "إلغاء", "voided": "ملغى", "confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟" } ``` --- ### PR-1 Test Plan **Unit/integration (add to `OrderSessionTests.cs`):** ``` ✓ Void item reduces order total ✓ Void already-voided item returns ITEM_ALREADY_VOIDED ✓ Void item on closed order returns ORDER_ALREADY_CLOSED ✓ Non-manager role returns 403 ``` **Manual:** 1. Open table, add 3 items. 2. As Manager, click Void on item 2 — confirm dialog appears. 3. Confirm — item shows strikethrough, total updates immediately. 4. As Cashier (non-manager), verify void button is hidden. 5. Pay remaining items — order closes normally. --- --- ## PR-2 — Transfer Table ### Goal Move an open order from one table to another. Clears source table, assigns order to target table. Blocked if target table is already occupied. ### Files to create / edit ``` src/Meezi.API/Services/OrderService.cs ← TransferTableAsync src/Meezi.API/Services/TableService.cs ← helper status check src/Meezi.API/Controllers/OrdersController.cs ← POST endpoint src/Meezi.API/DTOs/TransferTableRequest.cs ← new DTO web/dashboard/src/components/pos/pos-table-board.tsx ← transfer UI web/dashboard/src/lib/api/orders.ts ← transferTable() messages/fa.json, en.json, ar.json ← transfer strings ``` --- ### Step 1 — Request DTO ```csharp // src/Meezi.API/DTOs/TransferTableRequest.cs public record TransferTableRequest(Guid TargetTableId); ``` --- ### Step 2 — Service: `OrderService.cs` ```csharp public async Task> TransferTableAsync( Guid orderId, Guid targetTableId, CancellationToken ct = default) { var order = await _db.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct); if (order is null) return ApiResponse.Fail("ORDER_NOT_FOUND"); if (order.Status == OrderStatus.Closed) return ApiResponse.Fail("ORDER_ALREADY_CLOSED"); // Check target table exists and belongs to this cafe var targetTable = await _db.Tables .FirstOrDefaultAsync(t => t.Id == targetTableId && t.CafeId == _tenant.CafeId, ct); if (targetTable is null) return ApiResponse.Fail("TABLE_NOT_FOUND"); // Check target not occupied var targetOccupied = await _db.Orders.AnyAsync( o => o.TableId == targetTableId && o.CafeId == _tenant.CafeId && o.Status == OrderStatus.Open && o.Id != orderId, ct); if (targetOccupied) return ApiResponse.Fail("TABLE_OCCUPIED"); if (targetTable.IsCleaning) return ApiResponse.Fail("TABLE_CLEANING"); // Free source table (nothing to update on Table entity — occupancy is inferred from open orders) var sourceTableId = order.TableId; order.TableId = targetTableId; await _db.SaveChangesAsync(ct); return ApiResponse.Ok(_mapper.Map(order)); } ``` --- ### Step 3 — Controller: `OrdersController.cs` ```csharp /// Transfer an open order to another table. [HttpPost("{orderId}/transfer")] [Authorize(Roles = "Manager,Owner,Waiter")] public async Task TransferTable( Guid cafeId, Guid orderId, [FromBody] TransferTableRequest request, CancellationToken ct) { var result = await _orderService.TransferTableAsync(orderId, request.TargetTableId, ct); return result.IsSuccess ? Ok(result) : BadRequest(result); } ``` --- ### Step 4 — Dashboard: API helper ```typescript // web/dashboard/src/lib/api/orders.ts export async function transferTable( cafeId: string, orderId: string, targetTableId: string ): Promise { await apiClient.post( `/cafes/${cafeId}/orders/${orderId}/transfer`, { targetTableId } ); } ``` --- ### Step 5 — Dashboard: Transfer UI in `pos-table-board.tsx` Add a "Transfer" button in the order action bar (next to Pay). On click, show a table-picker modal listing free tables only: ```tsx // In pos-table-board.tsx — inside the active order action row {showTransferPicker && ( !t.isOccupied && !t.isCleaning && t.id !== currentTableId)} onSelect={async (tableId) => { await transferTable(cafeId, activeOrderId, tableId); setShowTransferPicker(false); router.push(`/pos?tableId=${tableId}&orderId=${activeOrderId}`); }} onClose={() => setShowTransferPicker(false)} /> )} ``` `TablePickerModal` is a simple grid of table buttons — reuse the existing table card style from the board. --- ### Step 6 — i18n strings ```json // messages/fa.json "pos": { "transferTable": "انتقال میز", "selectTargetTable": "میز مقصد را انتخاب کنید", "transferSuccess": "سفارش با موفقیت منتقل شد" } // messages/en.json "pos": { "transferTable": "Transfer Table", "selectTargetTable": "Select destination table", "transferSuccess": "Order transferred successfully" } // messages/ar.json "pos": { "transferTable": "نقل الطاولة", "selectTargetTable": "اختر الطاولة المستهدفة", "transferSuccess": "تم نقل الطلب بنجاح" } ``` --- ### PR-2 Test Plan **Integration tests:** ``` ✓ Transfer moves order to free target table ✓ Transfer to occupied table returns TABLE_OCCUPIED ✓ Transfer to cleaning table returns TABLE_CLEANING ✓ Transfer closed order returns ORDER_ALREADY_CLOSED ✓ Transfer to table in different cafe returns TABLE_NOT_FOUND ``` **Manual:** 1. Open two tables (A occupied, B free). 2. On table A's order, click Transfer Table. 3. Pick table B — verify board shows B occupied, A free. 4. Verify URL updates to `?tableId=B&orderId=...`. 5. Try transferring to occupied table — verify blocked with error toast. --- --- ## PR-3 — SignalR Table Board Realtime ### Goal When any order/payment/cleaning event fires, all connected dashboard clients see the board update without refresh. ### Files to create / edit ``` src/Meezi.API/Hubs/TableBoardHub.cs ← new SignalR hub src/Meezi.API/Services/BoardNotifier.cs ← new service, sends hub events src/Meezi.API/Extensions/ServiceCollectionExtensions.cs ← register hub + notifier src/Meezi.API/Program.cs ← map hub endpoint src/Meezi.API/Services/OrderService.cs ← call notifier after mutations src/Meezi.API/Services/TableService.cs ← call notifier after cleaning toggle web/dashboard/src/lib/hooks/useTableBoard.ts ← new hook, SignalR subscription web/dashboard/src/components/pos/pos-table-board.tsx ← use hook ``` --- ### Step 1 — Hub: `TableBoardHub.cs` ```csharp // src/Meezi.API/Hubs/TableBoardHub.cs using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; namespace Meezi.API.Hubs; [Authorize] public class TableBoardHub : Hub { // Clients join a cafe-specific group on connect public async Task JoinCafe(string cafeId) { await Groups.AddToGroupAsync(Context.ConnectionId, $"cafe:{cafeId}"); } public async Task LeaveCafe(string cafeId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"cafe:{cafeId}"); } } ``` --- ### Step 2 — Notifier: `BoardNotifier.cs` ```csharp // src/Meezi.API/Services/BoardNotifier.cs using Microsoft.AspNetCore.SignalR; using Meezi.API.Hubs; namespace Meezi.API.Services; public interface IBoardNotifier { Task TableUpdatedAsync(Guid cafeId, Guid tableId); Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId); } public class BoardNotifier : IBoardNotifier { private readonly IHubContext _hub; public BoardNotifier(IHubContext hub) { _hub = hub; } public async Task TableUpdatedAsync(Guid cafeId, Guid tableId) { await _hub.Clients .Group($"cafe:{cafeId}") .SendAsync("TableUpdated", new { tableId }); } public async Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId) { await _hub.Clients .Group($"cafe:{cafeId}") .SendAsync("OrderUpdated", new { orderId, tableId }); } } ``` --- ### Step 3 — Register in DI and map endpoint In `ServiceCollectionExtensions.cs`, add: ```csharp services.AddScoped(); services.AddSignalR(); // already added if KDS uses it — ensure not duplicated ``` In `Program.cs`, after `app.MapControllers()`: ```csharp app.MapHub("/hubs/table-board") .RequireAuthorization(); ``` --- ### Step 4 — Hook into services In `OrderService.cs`, inject `IBoardNotifier` via constructor. After every mutation that changes table state, call the notifier: ```csharp // After order closed (payment completes): await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId); // After void item: await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId); // After transfer: await _boardNotifier.TableUpdatedAsync(order.CafeId, sourceTableId); await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, targetTableId); ``` In `TableService.cs`, after cleaning toggle: ```csharp await _boardNotifier.TableUpdatedAsync(table.CafeId, table.Id); ``` --- ### Step 5 — Dashboard hook: `useTableBoard.ts` ```typescript // web/dashboard/src/lib/hooks/useTableBoard.ts import { useEffect, useCallback } from "react"; import * as signalR from "@microsoft/signalr"; let connection: signalR.HubConnection | null = null; export function useTableBoardRealtime( cafeId: string, onTableUpdated: (tableId: string) => void, onOrderUpdated: (orderId: string, tableId: string | null) => void ) { const connect = useCallback(async () => { if (connection) return; connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/table-board", { accessTokenFactory: () => getAccessToken(), // existing auth helper }) .withAutomaticReconnect() .build(); connection.on("TableUpdated", ({ tableId }: { tableId: string }) => { onTableUpdated(tableId); }); connection.on("OrderUpdated", ({ orderId, tableId }: { orderId: string; tableId: string | null }) => { onOrderUpdated(orderId, tableId); }); await connection.start(); await connection.invoke("JoinCafe", cafeId); }, [cafeId, onTableUpdated, onOrderUpdated]); useEffect(() => { connect(); return () => { connection?.stop(); connection = null; }; }, [connect]); } ``` Install SignalR client if not already present: ```bash cd web/dashboard npm install @microsoft/signalr ``` --- ### Step 6 — Wire into `pos-table-board.tsx` ```tsx // web/dashboard/src/components/pos/pos-table-board.tsx useTableBoardRealtime( cafeId, (_tableId) => { // Refresh the full board (or targeted table) reloadBoard(); }, (_orderId, _tableId) => { reloadBoard(); } ); ``` `reloadBoard` should call the existing `GET /tables` + `GET /orders/open` (or whatever the board currently uses). --- ### PR-3 Test Plan **Manual (two browser tabs):** 1. Open board in Tab A, same cafe in Tab B. 2. In Tab B, pay an order on Table 3. 3. Tab A — verify Table 3 changes to green (free) without refresh. 4. In Tab B, mark Table 5 as cleaning. 5. Tab A — verify Table 5 shows cleaning state within ~1 second. 6. Reload Tab A — verify reconnect works (SignalR `withAutomaticReconnect`). **Iran/network note:** SignalR falls back to long-polling if WebSocket is blocked. No extra config needed — `HubConnectionBuilder` handles this automatically. --- --- ## PR-4 — Receipt Print Preview ### Goal Thermal-style (80mm) print preview for a paid or open order. Uses `window.print()` — no hardware SDK required. ### Files to create / edit ``` web/dashboard/src/components/pos/pos-receipt-modal.tsx ← new component web/dashboard/src/components/pos/pos-receipt-print.css ← thermal print styles web/dashboard/src/components/pos/pos-pay-panel.tsx ← trigger after payment web/dashboard/src/components/pos/pos-table-board.tsx ← "Print" button on closed orders (optional) web/dashboard/src/lib/api/orders.ts ← getOrder() if not already messages/fa.json, en.json, ar.json ← receipt strings ``` --- ### Step 1 — Print CSS: `pos-receipt-print.css` ```css /* web/dashboard/src/components/pos/pos-receipt-print.css */ @media print { body * { visibility: hidden; } #receipt-print-area, #receipt-print-area * { visibility: visible; } #receipt-print-area { position: absolute; inset: 0; margin: 0; padding: 0; } } #receipt-print-area { width: 80mm; font-family: 'Courier New', monospace; font-size: 12px; direction: rtl; text-align: right; padding: 4mm; } .receipt-divider { border-top: 1px dashed #000; margin: 3mm 0; } .receipt-row { display: flex; justify-content: space-between; } .receipt-total { font-weight: bold; font-size: 14px; } ``` --- ### Step 2 — Receipt Component: `pos-receipt-modal.tsx` ```tsx // web/dashboard/src/components/pos/pos-receipt-modal.tsx "use client"; import { useTranslations } from "next-intl"; import "./pos-receipt-print.css"; import type { OrderDto } from "@/lib/api/types"; interface Props { order: OrderDto; cafeName: string; onClose: () => void; } export function PosReceiptModal({ order, cafeName, onClose }: Props) { const t = useTranslations("receipt"); const handlePrint = () => window.print(); const activeItems = order.items.filter((i) => !i.isVoided); const formattedDate = new Intl.DateTimeFormat("fa-IR", { dateStyle: "short", timeStyle: "short", }).format(new Date(order.createdAt)); return (
{/* Screen preview */}
{cafeName}
{formattedDate}
{t("table")}: {order.tableName ?? "—"} | {t("order")}: #{order.orderNumber}
{order.guestName && (
{t("guest")}: {order.guestName}
)}
{activeItems.map((item) => (
{item.productName} × {item.quantity} {formatCurrency(item.unitPrice * item.quantity)}
))}
{t("total")} {formatCurrency(order.totalAmount)}
{order.payments?.map((p, i) => (
{t(`payment.${p.method.toLowerCase()}`)} {formatCurrency(p.amount)}
))}
{t("thankYou")}
{/* Actions */}
); } function formatCurrency(amount: number) { return new Intl.NumberFormat("fa-IR").format(amount) + " تومان"; } ``` --- ### Step 3 — Trigger from `pos-pay-panel.tsx` After a successful payment response, show the receipt: ```tsx // After successful payment call in pos-pay-panel.tsx const [receiptOrder, setReceiptOrder] = useState(null); // On payment success: setReceiptOrder(paidOrder); // paidOrder = the OrderDto returned by the payment API // In JSX: {receiptOrder && ( { setReceiptOrder(null); onPaymentComplete(); // existing callback }} /> )} ``` --- ### Step 4 — i18n strings ```json // messages/fa.json "receipt": { "table": "میز", "order": "سفارش", "guest": "مهمان", "total": "مجموع", "print": "چاپ", "close": "بستن", "thankYou": "ممنون از انتخاب شما", "payment": { "cash": "نقد", "card": "کارت", "credit": "اعتبار" } } // messages/en.json "receipt": { "table": "Table", "order": "Order", "guest": "Guest", "total": "Total", "print": "Print", "close": "Close", "thankYou": "Thank you for your visit", "payment": { "cash": "Cash", "card": "Card", "credit": "Credit" } } // messages/ar.json "receipt": { "table": "الطاولة", "order": "الطلب", "guest": "الضيف", "total": "الإجمالي", "print": "طباعة", "close": "إغلاق", "thankYou": "شكراً على زيارتكم", "payment": { "cash": "نقداً", "card": "بطاقة", "credit": "رصيد" } } ``` --- ### PR-4 Test Plan **Manual:** 1. Open table, add items, pay (split cash + card). 2. Verify receipt modal auto-opens after payment. 3. Verify all items listed, voided items excluded. 4. Verify payment methods shown (Cash X, Card Y). 5. Click Print — browser print dialog opens; preview shows 80mm receipt layout. 6. Test in Persian (fa) — text is RTL, numbers in Persian numerals. 7. Close modal — board reflects freed table. --- --- ## PR-5 — Integration Test Coverage ### Goal Fill the gaps in `OrderSessionTests.cs` and add auth + payment integration tests. ### Files to create / edit ``` tests/Meezi.API.Tests/OrderSessionTests.cs ← extend existing tests/Meezi.API.Tests/OrderVoidTransferTests.cs ← new tests/Meezi.API.Tests/PaymentSplitTests.cs ← new tests/Meezi.API.Tests/AuthTests.cs ← new ``` --- ### `OrderVoidTransferTests.cs` — full file ```csharp // tests/Meezi.API.Tests/OrderVoidTransferTests.cs using System.Net; using System.Net.Http.Json; using Meezi.API.DTOs; using Xunit; namespace Meezi.API.Tests; public class OrderVoidTransferTests : IClassFixture { private readonly HttpClient _client; public OrderVoidTransferTests(MeeziWebApplicationFactory factory) { _client = factory.CreateAuthenticatedClient(role: "Manager"); } [Fact] public async Task VoidItem_ReducesOrderTotal() { // Arrange: create order with 2 items var (cafeId, orderId, items) = await CreateOrderWithItems(2); var itemId = items[0].Id; var originalTotal = items.Sum(i => i.UnitPrice * i.Quantity); // Act var response = await _client.PatchAsJsonAsync( $"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { }); // Assert response.EnsureSuccessStatusCode(); var order = await GetOrder(cafeId, orderId); Assert.Equal(originalTotal - items[0].UnitPrice, order.TotalAmount); } [Fact] public async Task VoidItem_AlreadyVoided_ReturnsBadRequest() { var (cafeId, orderId, items) = await CreateOrderWithItems(1); var itemId = items[0].Id; await _client.PatchAsJsonAsync( $"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { }); var response = await _client.PatchAsJsonAsync( $"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var error = await response.Content.ReadFromJsonAsync(); Assert.Equal("ITEM_ALREADY_VOIDED", error?.Code); } [Fact] public async Task TransferTable_MovesOrderToFreeTable() { var (cafeId, orderId, sourceTableId, targetTableId) = await SetupTwoTables(); var response = await _client.PostAsJsonAsync( $"/api/cafes/{cafeId}/orders/{orderId}/transfer", new { targetTableId }); response.EnsureSuccessStatusCode(); // Source table should be free Assert.False(await IsTableOccupied(cafeId, sourceTableId)); // Target table should be occupied Assert.True(await IsTableOccupied(cafeId, targetTableId)); } [Fact] public async Task TransferTable_ToOccupiedTable_ReturnsTableOccupied() { // Both tables occupied var (cafeId, order1Id, table1Id, _) = await SetupTwoTables(); var (_, order2Id, table2Id, _) = await SetupTwoTables(cafeId); var response = await _client.PostAsJsonAsync( $"/api/cafes/{cafeId}/orders/{order1Id}/transfer", new { targetTableId = table2Id }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var error = await response.Content.ReadFromJsonAsync(); Assert.Equal("TABLE_OCCUPIED", error?.Code); } // Helper methods omitted for brevity — follow MeeziWebApplicationFactory patterns } ``` --- ### `PaymentSplitTests.cs` — key cases ```csharp [Fact] public async Task SplitPayment_CashAndCard_ClosesOrder() { var (cafeId, orderId) = await CreateOrderWithTotal(1000m); var response = await _client.PostAsJsonAsync( $"/api/cafes/{cafeId}/orders/{orderId}/payments", new { payments = new[] { new { method = "Cash", amount = 600 }, new { method = "Card", amount = 400 } } }); response.EnsureSuccessStatusCode(); var order = await GetOrder(cafeId, orderId); Assert.Equal("Closed", order.Status); Assert.Equal(2, order.Payments.Length); } [Fact] public async Task Payment_FreesTableOnBoard() { var (cafeId, orderId, tableId) = await CreateOrderOnTable(); await _client.PostAsJsonAsync( $"/api/cafes/{cafeId}/orders/{orderId}/payments", new { payments = new[] { new { method = "Cash", amount = 500 } } }); var tableStatus = await GetTableStatus(cafeId, tableId); Assert.Equal("Free", tableStatus); } ``` --- ### `AuthTests.cs` — key cases ```csharp [Fact] public async Task UnauthorizedRequest_Returns401() { var anonClient = _factory.CreateClient(); // no auth var response = await anonClient.GetAsync("/api/cafes/00000000/orders"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task WrongCafeId_Returns403OrEmpty() { var response = await _client.GetAsync("/api/cafes/99999999/orders"); // Either 403 or empty list — not another cafe's data Assert.True( response.StatusCode == HttpStatusCode.Forbidden || (await response.Content.ReadFromJsonAsync>>())?.Data?.Count == 0 ); } ``` --- ### Run tests ```bash dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release --logger "console;verbosity=normal" ``` Expected: all existing 13 + new ~12 tests pass. --- --- ## PR-6 — Fix NU1903 Package Warnings ### Goal Eliminate the two vulnerability warnings that appear on every build. ### Warning 1: AutoMapper 12.0.1 **Option A — Bump AutoMapper (preferred):** ```xml ``` Then verify no breaking changes (AutoMapper 13 removed some static APIs): ```bash dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep -i error ``` **Option B — Replace with Mapperly (zero-reflection, .NET 10 ideal):** Only do this if AutoMapper 13 has breaking changes. Mapperly generates mapping code at compile time. Migration guide: https://mapperly.riok.app/docs/getting-started/migration/automapper/ --- ### Warning 2: System.Security.Cryptography.Xml 9.0.0 This is a transitive dependency. Pin it to the fixed version: ```xml ``` Then in the project that pulls it transitively (likely `Meezi.Infrastructure`): ```xml ``` (No version needed — central package management handles it.) --- ### Verify ```bash dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep NU1903 # Expected: no output ``` --- --- ## Implementation Order & Branching ``` main └── feature/void-order-line (PR-1, ~2h) └── feature/transfer-table (PR-2, depends on PR-1 merge) └── feature/signalr-board (PR-3, depends on PR-2) └── feature/receipt (PR-4, independent of PR-3 but nice with it) feature/integration-tests (PR-5, can start any time in parallel) feature/fix-nu1903 (PR-6, can start any time in parallel) ``` PRs 5 and 6 are independent — open them alongside PR-1 in parallel. --- ## Non-Negotiables (from `.cursorrules`) - Every EF query: `CafeId == _tenant.CafeId` - Responses: `ApiResponse` / `ApiError` with error codes - No hardcoded UI strings — all in `messages/{fa,ar,en}.json` - Dashboard CSS: `ms-*` / `me-*` only (RTL-safe) - Iran Docker: if any PR adds a new NuGet or npm package, document it; Docker pulls may need VPN/mirror - Tests: in-memory EF, `Testing:Enabled=true`, no live Redis/Hangfire in test mode --- ## Quick Reference — Error Codes Added This Sprint | Code | Meaning | |------|---------| | `ITEM_NOT_FOUND` | Line item ID not on this order | | `ITEM_ALREADY_VOIDED` | Tried to void an already-voided line | | `TABLE_CLEANING` | Target table is in cleaning state | | `ORDER_ALREADY_CLOSED` | Mutation attempted on a closed order | --- *End of plan — paste this file into Cursor and start with PR-1.*