# 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()` 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 / 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) ──────────────────────────────────────────────────────────────── 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 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 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 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.*