---
title: Branches and Scoping
description: Learn how branch isolation keeps stock and sales apart, and where admins cross the line.
---

### The branch as your boundary

Every stock row carries a `branch_id`. Batches, sales, transfers, and stock movements all point back to `branches(id)` in `packages/database/schema.sql`. Your JWT carries your home branch in its `branch_id` claim (`apps/server/src/core/auth/token.ts`), and handlers default to it unless the route explicitly allows an override. If you forget scoping, you leak another shop's data, so always filter by branch.

### Where the user row wins

`authMiddleware` in `apps/server/src/core/auth/middleware.ts` re reads `users` on every request and overwrites the token role and branch with the fresh row. That means a pharmacist moved to branch 2 loses branch 1 access on the next call, and a deactivated account gets a 401 immediately. Trust the `requireUser(context)` result from `apps/server/src/core/common/http.ts`, never the raw token payload, when you need the current branch. The same middleware also pins the tenant `search_path` through `apps/server/src/core/database/tenancy.ts`, so a request can never read another organization's tables.

### What pharmacists can touch

Pharmacists and cashiers operate inside their own branch. Sale creation takes `branchId` from the caller: `POST /sales` parses the body with `currentUser.branch_id` as the fallback in `apps/server/src/features/sales/route.ts`, and `createSale` in `apps/server/src/features/sales/creation/service.ts` only writes what that branch holds. The sync push path in `apps/server/src/features/sync/service.ts` uses the device branch from `requireDeviceBranchAccess` in `apps/server/src/features/sync/protocol/branch-scope.ts`, and re sales with a matching `client_mutation_id` return the original invoice instead of double charging.

Transfers are the exception: they name both `from_branch_id` and `to_branch_id` in `packages/database/schema.sql`, with a unique key on `(from_branch_id, client_mutation_id)` so the sending branch owns the dedupe.

### Where admins cross branches

Only `admin` bypasses `requireRoles(...)` checks in `apps/server/src/core/auth/middleware.ts`. Device registration is the clearest example: in `apps/server/src/features/sync/service.ts`, `registerDevice` accepts the `branch_id` from the body only when `currentUser.role === "admin"`, while everyone else is pinned to their own. The pull filter then serves changes whose `origin_branch_id` or `to_branch_id` matches the device branch, so an admin device still only sees what its registered branch may see.

### How to scope a new query

Add `WHERE branch_id = ?` with the current user branch as the parameter. For catalog reads, `GET /api/v1/sync/snapshot` is the model: `getSnapshot` in `apps/server/src/features/sync/service.ts` hands `listSnapshotBranches` a flag from `spansAllBranches` in `apps/server/src/features/sync/protocol/branch-scope.ts`, so an admin gets every branch and user while a branch caller only gets its own. For writes, pass the branch explicitly into `createSale` or `emitChange` from `apps/server/src/features/sync/protocol/emitter.ts` so the sync layer records the right `origin_branch_id`.

### What the pull filter guarantees

The `GET /api/v1/sync/pull` query is the enforcement point. `listVisibleChanges` in `apps/server/src/features/sync/queries.ts` compares the device branch against each change visibility, classified by `classifyVisibility` in `apps/server/src/core/common/visibility.ts`: `global` passes for all, `branch` only when `origin_branch_id` matches, `directed` when either side matches. A pharmacist on branch 1 therefore never receives branch 2 sales rows, even though both live in the same `sync_changes` table.

### Common scoping mistakes

New code usually breaks scoping in the same few ways.
Watch for these when you review a pull request.

- Missing `branch_id` filter on a new `SELECT`, leaking rows across shops
- Trusting the token claim instead of the fresh `requireUser(context)` result
- Letting non admins pass an explicit `branch_id` in a write body
- Emitting a sync change with the wrong `origin_branch_id` for the writer

### Try it with curl

Log in as each demo user and compare what branch you land in.

```bash
curl -s -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@demo.pharmaflow","password":"Demo@1234"}'
```

```bash
TOKEN="<paste access_token here>"
curl -s http://localhost:8000/api/v1/branches \
  -H "Authorization: Bearer $TOKEN"
```

### Next steps

- Read /architecture/request-lifecycle for how the branch claim reaches your handler
- Read /architecture/stock-and-fefo for branch-scoped batch deduction
- Read /sales/selling-at-the-counter for the register flow built on this scoping
