Files
meezi/docs/MEEZI_NEXT_SPRINT_PLAN.md
soroush.asadi 03376b3ea1 feat(docker): multi-stage Dockerfiles with npmmirror registry
Rewrites dashboard and finder Dockerfiles to use a clean multi-stage
build (deps → builder → runner) that installs npm packages inside
Alpine Linux, avoiding the SWC musl binary issue when building from
Windows host. Uses registry.npmmirror.com for reliable installs from
restricted networks (Iran).

- docker/api/Dockerfile: .NET 10 multi-stage build
- docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/website/Dockerfile: marketing website build
- scripts/: PowerShell helper scripts for local dev

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 21:33:29 +03:30

32 KiB
Raw Permalink Blame History

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:

// 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:

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

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:

// 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):

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

public bool IsVoided { get; set; }
public DateTime? VoidedAt { get; set; }

Add to AutoMapper profile if explicit mappings exist.


Step 7 — Dashboard: API helper

// 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:

// 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:

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

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

// src/Meezi.API/DTOs/TransferTableRequest.cs
public record TransferTableRequest(Guid TargetTableId);

Step 2 — Service: OrderService.cs

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

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

// 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:

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

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

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

// 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:

services.AddScoped<IBoardNotifier, BoardNotifier>();
services.AddSignalR(); // already added if KDS uses it — ensure not duplicated

In Program.cs, after app.MapControllers():

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:

// 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:

await _boardNotifier.TableUpdatedAsync(table.CafeId, table.Id);

Step 5 — Dashboard hook: useTableBoard.ts

// 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:

cd web/dashboard
npm install @microsoft/signalr

Step 6 — Wire into pos-table-board.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

/* 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

// 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:

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

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

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

[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

[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

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

<!-- 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):

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:

<!-- 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):

<!-- Meezi.Infrastructure.csproj — add: -->
<PackageReference Include="System.Security.Cryptography.Xml" />

(No version needed — central package management handles it.)


Verify

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.