Files
meezi/docs/MEEZI_BRANCH_CREDENTIALS_PLAN.md
T
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

39 KiB

Meezi — Branch Ownership Model + Branch Credentials

Copy-paste this entire file into Cursor. Stack: ASP.NET Core 10, EF Core 10, Next.js 14, next-intl, PostgreSQL 16 Prerequisite: Branch entity already exists (migration AddBranchEntity applied)


Architecture Decisions (read before coding)

Menu Ownership

  • MenuItem and MenuCategory live at the Cafe (owner) level — global catalog
  • Each branch gets a BranchMenuItemOverride row to disable or reprice an item
  • Branch managers CANNOT create new menu items — only toggle availability and override price (Pro+ plan)
  • No override row = item is active at master price (implicit inheritance)

Table Ownership

  • Tables are 100% per-branch — BranchId is required, non-nullable
  • Owner can manage any branch's tables; Branch Manager manages only their own
  • Table sections (سالن, تراس, VIP) are also per-branch

Staff / Credentials

  • One user account per person (global to cafe)
  • UserBranchAssignment links user → branch with a role
  • JWT contains active branchId claim
  • Multi-branch users get a branch-picker step after login
  • POS terminals use a short TerminalPin for quick device unlock (secondary auth)

Settings Inheritance

  • CafeSettings = owner defaults
  • BranchSettings = per-branch overrides (null = use cafe default)
  • A GetEffectiveSetting<T>() helper resolves the right value

PROMPT 1 — Menu Ownership: BranchMenuItemOverride

Context: Meezi POS, ASP.NET Core 10, EF Core 10.
MenuItem and MenuCategory already exist with CafeId (owner-level catalog).
Goal: Add per-branch availability and price override for menu items.

────────────────────────────────────────────────────────────────
STEP 1 — New entity: BranchMenuItemOverride
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/BranchMenuItemOverride.cs

Properties:
  - Id (Guid)
  - CafeId (Guid) — tenant guard
  - BranchId (Guid)
  - MenuItemId (Guid)
  - IsAvailable (bool, default true) — false = hidden at this branch
  - PriceOverride (decimal?) — null = use MenuItem.BasePrice
  - SortOrderOverride (int?) — null = use MenuItem.SortOrder
  - UpdatedAt (DateTime)
  - UpdatedByUserId (Guid)

Unique constraint: (BranchId, MenuItemId) — one override row per branch+item.
Navigation properties: Branch, MenuItem.

────────────────────────────────────────────────────────────────
STEP 2 — EF migration
────────────────────────────────────────────────────────────────

dotnet ef migrations add BranchMenuItemOverride \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API \
  --output-dir Data/Migrations

Verify migration creates:
  - branch_menu_item_overrides table
  - unique index on (branch_id, menu_item_id)
  - fk to branches, menu_items, app_users

────────────────────────────────────────────────────────────────
STEP 3 — Update MenuService to resolve effective menu per branch
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/MenuService.cs

Add method: GetBranchMenuAsync(Guid branchId, CancellationToken ct)

Logic:
  1. Load all MenuItems where CafeId == _tenant.CafeId and IsActive == true
  2. Load all BranchMenuItemOverride rows for this branchId
  3. For each item, apply override:
     - if override.IsAvailable == false → exclude from result
     - if override.PriceOverride != null → use override price
     - if override.SortOrderOverride != null → use override sort
  4. Return as BranchMenuItemDto list

BranchMenuItemDto:
  - All existing MenuItemDto fields
  - EffectivePrice (decimal) — resolved price
  - IsOverridden (bool) — true if any override row exists
  - HasPriceOverride (bool)

────────────────────────────────────────────────────────────────
STEP 4 — New endpoint for branch override management
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Controllers/MenuController.cs — add to existing controller:

GET  /api/cafes/{cafeId}/branches/{branchId}/menu
  → Returns resolved branch menu (effective prices, filtered unavailable)
  → Used by POS and KDS screens

PUT  /api/cafes/{cafeId}/branches/{branchId}/menu/{menuItemId}/override
  → Body: { isAvailable: bool, priceOverride: decimal? }
  → Creates or updates BranchMenuItemOverride row
  → Authorization: Owner always allowed; Manager allowed only if plan >= Pro
  → If Manager and plan == Free: return PLAN_LIMIT_REACHED
     with message: "Price overrides require Pro plan"

DELETE /api/cafes/{cafeId}/branches/{branchId}/menu/{menuItemId}/override
  → Removes override row (resets to catalog defaults)
  → Owner only

────────────────────────────────────────────────────────────────
STEP 5 — Plan gate in BranchMenuOverrideService
────────────────────────────────────────────────────────────────

In UpsertOverrideAsync:
  - If request.PriceOverride != null:
    → Check cafe plan tier
    → Free plan: return ApiResponse.Fail("PLAN_LIMIT_REACHED",
        "قیمت‌گذاری شعبه‌ای نیاز به پلن Pro دارد")
    → Pro+: allow

────────────────────────────────────────────────────────────────
STEP 6 — Update POS screen to use branch menu endpoint
────────────────────────────────────────────────────────────────

File: web/dashboard/src/lib/api/menu.ts

Update getBranchMenu() to call:
  GET /api/cafes/{cafeId}/branches/{branchId}/menu
instead of the global menu endpoint.

The cart.store.ts should use effective price from BranchMenuItemDto.EffectivePrice,
not MenuItem.BasePrice.

────────────────────────────────────────────────────────────────
STEP 7 — Dashboard: Branch Menu Management UI
────────────────────────────────────────────────────────────────

File: web/dashboard/src/components/menu/branch-menu-overrides.tsx (new)

UI: A table showing all menu items for the selected branch:
  Columns: نام | قیمت اصلی | قیمت شعبه | وضعیت | عملیات
  
  - Toggle switch per item for IsAvailable
  - Price input field (Pro plan only — show lock icon + upgrade CTA on Free)
  - "بازنشانی" (Reset) button per item to delete override
  - Visual indicator on rows with active overrides

This component is embedded in the existing /menu page under a "تنظیمات شعبه" tab,
visible only when a specific branch is selected (not in "all branches" view).

────────────────────────────────────────────────────────────────
STEP 8 — i18n strings
────────────────────────────────────────────────────────────────

Add to messages/fa.json, en.json, ar.json under "branchMenu" key:

fa.json:
"branchMenu": {
  "title": "منوی شعبه",
  "masterPrice": "قیمت اصلی",
  "branchPrice": "قیمت شعبه",
  "availability": "وضعیت",
  "available": "فعال",
  "unavailable": "غیرفعال",
  "resetOverride": "بازنشانی",
  "priceOverridePro": "قیمت‌گذاری اختصاصی برای پلن Pro",
  "overrideActive": "تنظیمات شعبه فعال",
  "confirmReset": "آیا می‌خواهید تنظیمات این آیتم را به حالت پیش‌فرض برگردانید؟"
}

en.json:
"branchMenu": {
  "title": "Branch Menu",
  "masterPrice": "Master Price",
  "branchPrice": "Branch Price",
  "availability": "Status",
  "available": "Active",
  "unavailable": "Hidden",
  "resetOverride": "Reset",
  "priceOverridePro": "Price overrides require Pro plan",
  "overrideActive": "Branch override active",
  "confirmReset": "Reset this item to catalog defaults?"
}

────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/BranchMenuTests.cs)
────────────────────────────────────────────────────────────────

✓ GetBranchMenu_ExcludesUnavailableItems
✓ GetBranchMenu_AppliesPriceOverride_WhenSet
✓ GetBranchMenu_UsesMasterPrice_WhenNoOverride
✓ UpsertOverride_FreePlan_PriceOverride_ReturnsPlanLimitReached
✓ UpsertOverride_ProPlan_PriceOverride_Succeeds
✓ DeleteOverride_ResetsToMasterPrice
✓ Override_UniqueConstraint_UpsertNotDuplicate

Conventions:
  - All EF queries: CafeId == _tenant.CafeId
  - Response: ApiResponse<T> / ApiError with codes
  - No hardcoded strings in dashboard — all in messages/*.json
  - RTL: ms-*/me-* only in CSS

PROMPT 2 — Table Ownership: Per-Branch Tables + Sections

Context: Meezi POS, ASP.NET Core 10, EF Core 10.
Table entity currently has CafeId. Branch entity exists.
Goal: Make tables fully branch-owned. Add table sections per branch.

────────────────────────────────────────────────────────────────
STEP 1 — Add BranchId to Table (if not already added)
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/Table.cs

Add:
  public Guid BranchId { get; set; }
  public Guid? SectionId { get; set; }  // nullable — section is optional
  public virtual Branch Branch { get; set; } = null!;
  public virtual TableSection? Section { get; set; }

────────────────────────────────────────────────────────────────
STEP 2 — New entity: TableSection
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/TableSection.cs

Properties:
  - Id (Guid)
  - CafeId (Guid)
  - BranchId (Guid)
  - Name (string) — e.g. "سالن اصلی", "تراس", "VIP", "روف‌گاردن"
  - SortOrder (int, default 0)
  - IsActive (bool, default true)
  Navigation: Branch, Tables (ICollection<Table>)

────────────────────────────────────────────────────────────────
STEP 3 — EF migration
────────────────────────────────────────────────────────────────

dotnet ef migrations add BranchTableOwnership \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API \
  --output-dir Data/Migrations

Migration must:
  - Add branch_id (Guid, not null) to tables
    → For existing rows: set to the cafe's default/first branch
    → Script: UPDATE tables SET branch_id = (SELECT id FROM branches
               WHERE cafe_id = tables.cafe_id LIMIT 1)
  - Add section_id (Guid, nullable) to tables
  - Create table_sections table
  - Add FK tables.branch_id → branches.id
  - Add FK tables.section_id → table_sections.id

────────────────────────────────────────────────────────────────
STEP 4 — Update TablesController + TableService
────────────────────────────────────────────────────────────────

All existing table endpoints already scoped by cafeId.
Add branchId scoping:

GET  /api/cafes/{cafeId}/branches/{branchId}/tables
  → Returns tables for this branch only
  → Includes section name in TableDto

POST /api/cafes/{cafeId}/branches/{branchId}/tables
  → Creates table for this branch

PATCH /api/cafes/{cafeId}/branches/{branchId}/tables/{id}
  → Updates table (name, capacity, section, sort order)

DELETE /api/cafes/{cafeId}/branches/{branchId}/tables/{id}
  → Soft delete — only if no open order on table
  → Returns TABLE_HAS_OPEN_ORDER if blocked

Sections endpoints:
GET    /api/cafes/{cafeId}/branches/{branchId}/tables/sections
POST   /api/cafes/{cafeId}/branches/{branchId}/tables/sections
PATCH  /api/cafes/{cafeId}/branches/{branchId}/tables/sections/{id}
DELETE /api/cafes/{cafeId}/branches/{branchId}/tables/sections/{id}
  → Cannot delete section with active tables → TABLE_SECTION_HAS_TABLES

Authorization:
  Owner: full CRUD on any branch
  Manager: CRUD on their assigned branch only
  Waiter: read-only (GET only)

In TableService, all queries:
  .Where(t => t.CafeId == _tenant.CafeId && t.BranchId == branchId)

────────────────────────────────────────────────────────────────
STEP 5 — Update POS board to pass branchId
────────────────────────────────────────────────────────────────

File: web/dashboard/src/components/pos/pos-table-board.tsx

Currently fetches tables for whole cafe.
Update to fetch: GET /branches/{activeBranchId}/tables

activeBranchId comes from:
  1. JWT claim (decoded from token)
  2. Or branch context stored in Zustand/session

Group tables by section in the board UI:
  - Section headers: "سالن اصلی", "تراس", etc.
  - Tables within each section as cards
  - "بدون بخش" group for tables with no section

────────────────────────────────────────────────────────────────
STEP 6 — Dashboard: Table Management UI
────────────────────────────────────────────────────────────────

File: web/dashboard/src/components/tables/tables-screen.tsx

Add branch selector at top (only visible to Owner — managers see their branch only).
Add section management panel:
  - List sections with edit/delete
  - Drag to reorder sections
  - Assign table to section via dropdown on table card

────────────────────────────────────────────────────────────────
STEP 7 — i18n strings
────────────────────────────────────────────────────────────────

Add under "tables" key:
fa.json:
"tables": {
  "section": "بخش",
  "sections": "بخش‌ها",
  "addSection": "افزودن بخش",
  "noSection": "بدون بخش",
  "sectionHasTables": "این بخش دارای میز است و قابل حذف نیست",
  "tableHasOpenOrder": "این میز دارای سفارش باز است"
}

────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/BranchTableTests.cs)
────────────────────────────────────────────────────────────────

✓ GetTables_ReturnsBranchTablesOnly
✓ GetTables_DoesNotReturnOtherBranchTables
✓ CreateTable_AssignsToBranch
✓ DeleteTable_WithOpenOrder_ReturnsTableHasOpenOrder
✓ DeleteSection_WithTables_ReturnsTableSectionHasTables
✓ ManagerCannotAccessOtherBranchTables

PROMPT 3 — Branch Credentials: UserBranchAssignment + JWT

Context: Meezi POS, ASP.NET Core 10. AppUser, Branch entities exist.
Goal: Implement per-branch staff assignment, branch-scoped JWT, and
      branch selector flow after login.

────────────────────────────────────────────────────────────────
STEP 1 — New entity: UserBranchAssignment
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/UserBranchAssignment.cs

Properties:
  - Id (Guid)
  - CafeId (Guid)
  - UserId (Guid)
  - BranchId (Guid)
  - Role (enum: Owner | Manager | Cashier | Waiter | KitchenStaff)
  - IsActive (bool, default true)
  - AssignedAt (DateTime)
  - AssignedByUserId (Guid)
  Navigation: User, Branch, AssignedBy

Unique constraint: (UserId, BranchId) — one role per user per branch.

Note: Owner role assignment is auto-created when a branch is created.
      Owner can be assigned to all branches automatically.

────────────────────────────────────────────────────────────────
STEP 2 — Update AppUser
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/AppUser.cs

Add navigation:
  public virtual ICollection<UserBranchAssignment> BranchAssignments { get; set; }

Remove any direct Role property if it exists — role is now per-branch.
Keep a CafeRole (Owner/Admin) for cafe-level operations if needed.

────────────────────────────────────────────────────────────────
STEP 3 — EF migration
────────────────────────────────────────────────────────────────

dotnet ef migrations add UserBranchAssignment \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API \
  --output-dir Data/Migrations

Migration must:
  - Create user_branch_assignments table
  - Unique index on (user_id, branch_id)
  - FK to app_users, branches
  - Seed: for existing users, create assignment rows based on
    their current role + the cafe's first/default branch

────────────────────────────────────────────────────────────────
STEP 4 — Update JWT claims
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/AuthService.cs (or TokenService.cs)

Current token likely contains: sub, cafeId, role, exp
Update to contain:

{
  "sub": "userId",
  "cafeId": "xxx",
  "branchId": "yyy",       ← active branch for this session
  "role": "Manager",       ← role in THAT branch
  "branchIds": ["yyy","zzz"],  ← all branches this user can access (for picker UI)
  "exp": ...
}

Method: GenerateBranchToken(AppUser user, Guid branchId)
  1. Load UserBranchAssignment for this user + branchId
  2. If not found or not active → throw UnauthorizedException
  3. Load all active assignments for this user → branchIds claim
  4. Build token with branchId + role from assignment

────────────────────────────────────────────────────────────────
STEP 5 — New auth endpoints
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Controllers/AuthController.cs — add:

POST /api/auth/login
  (existing — unchanged, but response now includes branchIds list)

  Response:
  {
    "token": "...",         ← if user has exactly ONE branch, this is branch-scoped
    "requiresBranchSelect": true,   ← if user has multiple branches
    "branches": [           ← list for the picker UI
      { "id": "yyy", "name": "شعبه ولیعصر", "role": "Manager" },
      { "id": "zzz", "name": "شعبه کرج", "role": "Cashier" }
    ]
  }

POST /api/auth/select-branch   [Authorize] — requires valid login token
  Body: { "branchId": "yyy" }
  → Validates user is assigned to this branch
  → Returns a new branch-scoped JWT
  → Old token is invalidated (or just let it expire — branch select issues new token)

  Response:
  {
    "token": "...",   ← branch-scoped token with branchId + role claims
    "branchName": "شعبه ولیعصر",
    "role": "Manager"
  }

POST /api/auth/switch-branch   [Authorize] — for already-logged-in users
  Body: { "branchId": "zzz" }
  → Same as select-branch but can be called mid-session
  → Returns new token for the new branch
  → Used when owner wants to switch between branches without re-login

────────────────────────────────────────────────────────────────
STEP 6 — Update ITenantService to resolve BranchId from JWT
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/TenantService.cs (or middleware)

Current: resolves CafeId from JWT claim "cafeId"
Update: also resolve BranchId from JWT claim "branchId"

Interface:
public interface ITenantContext
{
    Guid CafeId { get; }
    Guid? BranchId { get; }   // nullable — owner-level calls may not have branchId
    string Role { get; }
    Guid UserId { get; }
}

In all service methods that need branch scope:
  var branchId = _tenant.BranchId
    ?? throw new InvalidOperationException("Branch context required");

For owner-level endpoints (e.g. menu catalog management):
  Only CafeId needed, BranchId can be null.

────────────────────────────────────────────────────────────────
STEP 7 — Staff Management endpoints
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Controllers/HrController.cs — add branch assignment endpoints:

GET  /api/cafes/{cafeId}/branches/{branchId}/staff
  → List all UserBranchAssignment for this branch (active only)
  → Includes user name, phone, role, assignedAt

POST /api/cafes/{cafeId}/branches/{branchId}/staff
  Body: { userId, role }
  → Creates UserBranchAssignment
  → If user doesn't exist yet, caller should first POST /api/cafes/{cafeId}/users
  → Owner only

PATCH /api/cafes/{cafeId}/branches/{branchId}/staff/{userId}
  Body: { role?, isActive? }
  → Updates assignment (change role or deactivate)
  → Owner only; Manager can deactivate but not change roles

DELETE /api/cafes/{cafeId}/branches/{branchId}/staff/{userId}
  → Soft delete (sets isActive = false)
  → Cannot remove the last active Owner from a branch

GET /api/cafes/{cafeId}/users
  → All users belonging to this cafe (unfiltered by branch)
  → Owner only — used to populate "add staff to branch" picker

────────────────────────────────────────────────────────────────
STEP 8 — TerminalPin: POS device quick-access
────────────────────────────────────────────────────────────────

For shared POS tablets where multiple cashiers use the same device.
Not a replacement for full auth — just a quick unlock per shift.

File: src/Meezi.Core/Entities/AppUser.cs — add:
  public string? TerminalPin { get; set; }  // 4-6 digit PIN, hashed

File: src/Meezi.API/Controllers/AuthController.cs — add:

POST /api/auth/pin-login
  Body: { cafeId, branchId, pin }
  [AllowAnonymous]
  → Finds user with matching PIN assigned to this branch
  → Returns a short-lived token (2h expiry, "Cashier" or actual role)
  → Rate limited: max 5 attempts per branchId per 15 minutes
  → Returns PIN_INVALID or BRANCH_NOT_FOUND on failure (never reveals which)

In AuthService:
  VerifyPin(string inputPin, string storedHash) — use BCrypt or Argon2
  
PIN management endpoint:
  PATCH /api/cafes/{cafeId}/users/{userId}/pin
  Body: { pin: "1234" }
  → Owner or the user themselves
  → Validate: 4-6 digits, not trivially sequential (1234, 0000)
  → Hash and store

────────────────────────────────────────────────────────────────
STEP 9 — Dashboard: Branch Selector UI (post-login)
────────────────────────────────────────────────────────────────

File: web/dashboard/src/app/[locale]/select-branch/page.tsx (new)

Shown after login when requiresBranchSelect === true.
Shows a card grid of branches the user is assigned to.
Each card: branch name, address, user's role at that branch.
On click → POST /auth/select-branch → store new token → redirect to dashboard.

If user has only 1 branch → skip this page, auto-redirect to dashboard.

File: web/dashboard/src/lib/stores/auth.store.ts (or existing auth store)

Add:
  activeBranchId: string | null
  activeBranchName: string | null
  availableBranches: { id, name, role }[]
  switchBranch: (branchId: string) => Promise<void>

switchBranch:
  → POST /auth/switch-branch
  → Update token in storage
  → Update activeBranchId in store
  → Trigger full data reload (invalidate all React Query cache)

File: web/dashboard/src/components/layout/sidebar.tsx (or nav)
  
Add branch switcher dropdown in header:
  - Shows current branch name
  - Dropdown lists other assigned branches
  - Click → switchBranch()
  - Only visible if user has 2+ branches
  - Owner always sees all branches

────────────────────────────────────────────────────────────────
STEP 10 — i18n strings
────────────────────────────────────────────────────────────────

messages/fa.json — add:
"auth": {
  "selectBranch": "انتخاب شعبه",
  "selectBranchPrompt": "لطفاً شعبه مورد نظر خود را انتخاب کنید",
  "switchBranch": "تغییر شعبه",
  "currentBranch": "شعبه فعال",
  "pinLogin": "ورود با پین",
  "enterPin": "پین خود را وارد کنید",
  "pinInvalid": "پین نادرست است",
  "pinTooManyAttempts": "تعداد تلاش‌های مجاز تمام شد. لطفاً ۱۵ دقیقه صبر کنید"
},
"staff": {
  "title": "کارکنان",
  "addStaff": "افزودن کارمند",
  "role": "نقش",
  "assignedAt": "تاریخ تخصیص",
  "deactivate": "غیرفعال کردن",
  "roles": {
    "Owner": "مالک",
    "Manager": "مدیر",
    "Cashier": "صندوقدار",
    "Waiter": "گارسون",
    "KitchenStaff": "آشپزخانه"
  }
}

messages/en.json — add:
"auth": {
  "selectBranch": "Select Branch",
  "selectBranchPrompt": "Please select your branch to continue",
  "switchBranch": "Switch Branch",
  "currentBranch": "Active Branch",
  "pinLogin": "PIN Login",
  "enterPin": "Enter your PIN",
  "pinInvalid": "Invalid PIN",
  "pinTooManyAttempts": "Too many attempts. Please wait 15 minutes"
},
"staff": {
  "title": "Staff",
  "addStaff": "Add Staff",
  "role": "Role",
  "assignedAt": "Assigned",
  "deactivate": "Deactivate",
  "roles": {
    "Owner": "Owner",
    "Manager": "Manager",
    "Cashier": "Cashier",
    "Waiter": "Waiter",
    "KitchenStaff": "Kitchen Staff"
  }
}

────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/BranchCredentialsTests.cs)
────────────────────────────────────────────────────────────────

✓ Login_SingleBranch_ReturnsBranchScopedToken
✓ Login_MultiBranch_ReturnsRequiresBranchSelect
✓ SelectBranch_ValidAssignment_ReturnsBranchToken
✓ SelectBranch_UnassignedBranch_ReturnsUnauthorized
✓ SwitchBranch_UpdatesTokenClaims
✓ PinLogin_ValidPin_ReturnsShortLivedToken
✓ PinLogin_InvalidPin_ReturnsPinInvalid
✓ PinLogin_FiveFailures_ReturnsRateLimited
✓ ManagerCannotSeeOtherBranchStaff
✓ CannotRemoveLastOwnerFromBranch
✓ GetTables_UsesJwtBranchId_ReturnsCorrectBranch

PROMPT 4 — Branch Settings Inheritance

Context: Meezi POS, ASP.NET Core 10, EF Core 10.
CafeSettings entity exists with owner-level defaults.
Goal: Add BranchSettings that override cafe defaults using fallback resolution.

────────────────────────────────────────────────────────────────
STEP 1 — New entity: BranchSettings
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/BranchSettings.cs

Properties (all nullable — null = use cafe default):
  - Id (Guid)
  - CafeId (Guid)
  - BranchId (Guid, unique)
  - ReceiptHeader (string?)    — override cafe receipt header
  - ReceiptFooter (string?)    — e.g. branch address / phone
  - TaxRate (decimal?)         — local tax override
  - ServiceCharge (decimal?)   — optional service charge %
  - OperatingHours (string?)   — JSON: { mon: {open:"08:00", close:"23:00"}, ... }
  - WifiPassword (string?)     — shown on receipt optionally
  - Currency (string?)         — for future multi-currency, default "IRR"
  - UpdatedAt (DateTime)

Unique constraint: one row per branch.

────────────────────────────────────────────────────────────────
STEP 2 — EF migration
────────────────────────────────────────────────────────────────

dotnet ef migrations add BranchSettings \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API \
  --output-dir Data/Migrations

────────────────────────────────────────────────────────────────
STEP 3 — EffectiveSettingsService
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/EffectiveSettingsService.cs (new)

public class EffectiveSettingsService
{
    // Returns branch value if set, otherwise cafe default
    public async Task<BranchEffectiveSettingsDto> GetEffectiveSettingsAsync(
        Guid cafeId, Guid branchId, CancellationToken ct)
    {
        var cafeSettings = await _db.CafeSettings
            .FirstOrDefaultAsync(s => s.CafeId == cafeId, ct);

        var branchSettings = await _db.BranchSettings
            .FirstOrDefaultAsync(s => s.BranchId == branchId, ct);

        // Resolve with fallback
        return new BranchEffectiveSettingsDto
        {
            ReceiptHeader = branchSettings?.ReceiptHeader ?? cafeSettings?.ReceiptHeader,
            ReceiptFooter = branchSettings?.ReceiptFooter ?? cafeSettings?.ReceiptFooter,
            TaxRate = branchSettings?.TaxRate ?? cafeSettings?.TaxRate ?? 0m,
            ServiceCharge = branchSettings?.ServiceCharge ?? cafeSettings?.ServiceCharge ?? 0m,
            OperatingHours = branchSettings?.OperatingHours ?? cafeSettings?.OperatingHours,
            // IsOverridden flags for UI (show which settings are branch-specific)
            TaxRateIsOverridden = branchSettings?.TaxRate != null,
            ReceiptIsOverridden = branchSettings?.ReceiptHeader != null
                               || branchSettings?.ReceiptFooter != null,
        };
    }
}

────────────────────────────────────────────────────────────────
STEP 4 — Settings endpoints
────────────────────────────────────────────────────────────────

Add to existing CafeSettingsController or new BranchSettingsController:

GET   /api/cafes/{cafeId}/branches/{branchId}/settings
  → Returns BranchEffectiveSettingsDto (resolved with fallback)
  → Shows which fields are overridden vs inherited

PATCH /api/cafes/{cafeId}/branches/{branchId}/settings
  Body: partial BranchSettings fields
  → Upserts BranchSettings row
  → Only fields included in body are updated
  → Tax rate override: Owner only (or Manager on Pro+ plan)

DELETE /api/cafes/{cafeId}/branches/{branchId}/settings/{field}
  → Clears a specific override field (resets to cafe default)
  → e.g. DELETE .../settings/taxRate

────────────────────────────────────────────────────────────────
STEP 5 — Use effective settings in receipt + order flow
────────────────────────────────────────────────────────────────

In OrderService.CloseOrderAsync:
  var settings = await _effectiveSettings.GetEffectiveSettingsAsync(cafeId, branchId, ct);
  order.TaxAmount = order.SubTotal * settings.TaxRate;
  order.ServiceCharge = order.SubTotal * settings.ServiceCharge;

In PosReceiptModal (dashboard):
  Fetch GET /branches/{branchId}/settings on receipt open.
  Show ReceiptHeader, ReceiptFooter, WifiPassword on receipt.

────────────────────────────────────────────────────────────────
TESTS
────────────────────────────────────────────────────────────────

✓ GetEffectiveSettings_UsesBranchOverride_WhenSet
✓ GetEffectiveSettings_FallsBackToCafe_WhenNoOverride
✓ GetEffectiveSettings_BothNull_ReturnsDefaults
✓ PatchSettings_OnlyUpdatesIncludedFields
✓ DeleteSettingField_ResetsToInheritedValue
✓ OrderClose_AppliesBranchTaxRate

Execution Order

Branch 1: feature/branch-menu-ownership
  → PROMPT 1 (BranchMenuItemOverride)
  → Tests: BranchMenuTests.cs

Branch 2: feature/branch-table-sections   (can parallel with branch 1)
  → PROMPT 2 (TableSection + BranchId on Table)
  → Tests: BranchTableTests.cs

Branch 3: feature/branch-credentials      (depends on branch 1+2 merged)
  → PROMPT 3 (UserBranchAssignment + JWT + PIN)
  → Tests: BranchCredentialsTests.cs

Branch 4: feature/branch-settings         (depends on branch 3 merged)
  → PROMPT 4 (BranchSettings + EffectiveSettingsService)
  → Tests: inline in SettingsTests.cs

Permission Matrix (implement in all service methods)

Action Owner Manager (own branch) Cashier Waiter
Create/edit menu items
Toggle item availability at branch
Override item price at branch Pro+ only
Create/edit tables own branch
Create table sections own branch
Assign staff to branch
Deactivate staff at branch own branch
Edit branch settings own branch
Override tax rate Pro+ only
View other branch data
Switch active branch all if assigned
PIN login

New Error Codes This Sprint

Code Meaning
BRANCH_NOT_FOUND BranchId not found or wrong cafe
REQUIRES_BRANCH_SELECT Login succeeded but branch must be chosen
BRANCH_UNASSIGNED User not assigned to requested branch
PIN_INVALID PIN login failed
PIN_RATE_LIMITED Too many PIN attempts
TABLE_SECTION_HAS_TABLES Cannot delete section with active tables
LAST_OWNER_PROTECTED Cannot remove last owner from branch
PLAN_LIMIT_BRANCH_MENU Price override requires Pro plan

End of plan — start with PROMPT 1 (BranchMenuItemOverride), it has zero auth dependencies.