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

853 lines
39 KiB
Markdown

# 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.*