# 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<ApiResponse<OrderDto>> 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<OrderDto>.Fail("ORDER_NOT_FOUND");

    if (order.Status == OrderStatus.Closed)
        return ApiResponse<OrderDto>.Fail("ORDER_ALREADY_CLOSED");

    var item = order.Items.FirstOrDefault(i => i.Id == itemId);
    if (item is null)
        return ApiResponse<OrderDto>.Fail("ITEM_NOT_FOUND");

    if (item.IsVoided)
        return ApiResponse<OrderDto>.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<OrderDto>.Ok(_mapper.Map<OrderDto>(order));
}
```

---

### Step 5 — Controller: `OrdersController.cs`

Add endpoint (inside the existing controller class):

```csharp
// src/Meezi.API/Controllers/OrdersController.cs

/// <summary>Void a single line item. Requires Manager role.</summary>
[HttpPatch("{orderId}/items/{itemId}/void")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> 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<void> {
  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 && (
  <button
    className="text-red-500 text-xs ms-2 hover:underline"
    onClick={() => handleVoidItem(item.id)}
    aria-label={t("pos.voidItem")}
  >
    {t("pos.void")}
  </button>
)}

{item.isVoided && (
  <span className="text-muted-foreground line-through text-xs">
    {t("pos.voided")}
  </span>
)}
```

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<ApiResponse<OrderDto>> 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<OrderDto>.Fail("ORDER_NOT_FOUND");

    if (order.Status == OrderStatus.Closed)
        return ApiResponse<OrderDto>.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<OrderDto>.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<OrderDto>.Fail("TABLE_OCCUPIED");

    if (targetTable.IsCleaning)
        return ApiResponse<OrderDto>.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<OrderDto>.Ok(_mapper.Map<OrderDto>(order));
}
```

---

### Step 3 — Controller: `OrdersController.cs`

```csharp
/// <summary>Transfer an open order to another table.</summary>
[HttpPost("{orderId}/transfer")]
[Authorize(Roles = "Manager,Owner,Waiter")]
public async Task<IActionResult> 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<void> {
  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

<button
  className="btn-outline text-sm"
  onClick={() => setShowTransferPicker(true)}
>
  {t("pos.transferTable")}
</button>

{showTransferPicker && (
  <TablePickerModal
    freeTables={tables.filter(t => !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<TableBoardHub> _hub;

    public BoardNotifier(IHubContext<TableBoardHub> 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<IBoardNotifier, BoardNotifier>();
services.AddSignalR(); // already added if KDS uses it — ensure not duplicated
```

In `Program.cs`, after `app.MapControllers()`:
```csharp
app.MapHub<TableBoardHub>("/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 (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
      <div className="bg-white rounded-xl p-4 w-[340px] shadow-xl">
        {/* Screen preview */}
        <div id="receipt-print-area" className="border border-dashed border-gray-300 p-3 mb-4">
          <div className="text-center font-bold text-base mb-1">{cafeName}</div>
          <div className="text-center text-xs text-gray-500 mb-2">{formattedDate}</div>
          <div className="text-xs mb-1">
            {t("table")}: {order.tableName ?? "—"} | {t("order")}: #{order.orderNumber}
          </div>
          {order.guestName && (
            <div className="text-xs mb-1">{t("guest")}: {order.guestName}</div>
          )}

          <div className="receipt-divider" />

          {activeItems.map((item) => (
            <div key={item.id} className="receipt-row text-xs mb-1">
              <span>{item.productName} × {item.quantity}</span>
              <span>{formatCurrency(item.unitPrice * item.quantity)}</span>
            </div>
          ))}

          <div className="receipt-divider" />

          <div className="receipt-row receipt-total">
            <span>{t("total")}</span>
            <span>{formatCurrency(order.totalAmount)}</span>
          </div>

          {order.payments?.map((p, i) => (
            <div key={i} className="receipt-row text-xs mt-1">
              <span>{t(`payment.${p.method.toLowerCase()}`)}</span>
              <span>{formatCurrency(p.amount)}</span>
            </div>
          ))}

          <div className="receipt-divider" />
          <div className="text-center text-xs mt-2">{t("thankYou")}</div>
        </div>

        {/* Actions */}
        <div className="flex gap-2">
          <button
            className="flex-1 btn-primary text-sm"
            onClick={handlePrint}
          >
            {t("print")}
          </button>
          <button
            className="flex-1 btn-outline text-sm"
            onClick={onClose}
          >
            {t("close")}
          </button>
        </div>
      </div>
    </div>
  );
}

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<OrderDto | null>(null);

// On payment success:
setReceiptOrder(paidOrder); // paidOrder = the OrderDto returned by the payment API

// In JSX:
{receiptOrder && (
  <PosReceiptModal
    order={receiptOrder}
    cafeName={cafe.name}
    onClose={() => {
      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<MeeziWebApplicationFactory>
{
    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<ApiError>();
        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<ApiError>();
        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<ApiResponse<List<OrderDto>>>())?.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
<!-- Directory.Packages.props -->
<!-- Replace: -->
<PackageVersion Include="AutoMapper" Version="12.0.1" />
<!-- With: -->
<PackageVersion Include="AutoMapper" Version="13.0.1" />
```

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
<!-- Directory.Packages.props — add: -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="9.0.5" />
```

Then in the project that pulls it transitively (likely `Meezi.Infrastructure`):
```xml
<!-- Meezi.Infrastructure.csproj — add: -->
<PackageReference Include="System.Security.Cryptography.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<T>` / `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.*
