# Flow — partner-center

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** partner-center owner / center admin («مدیر مرکز») · **Status:** mocked
**Client:** mock · **Server:** partial
**Business source:** [product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md)
**Integration:** [docs/integration/domains/partner-center.md](../integration/domains/partner-center.md)

## What it does

A licensed nursing center («مرکز») sponsors nurses onto Balinyaar and — when it is the **merchant of
record** — is the legal seller: it issues the customer invoice and receives settlement, with Balinyaar's cut
booked as a commission against it. The portal is the center owner's own read-only window: who they sponsor,
which bookings they legally cover, and what they are owed. It is a **separate authz scope from `/admin`**.

**The core finding:** there is **no partner-facing controller**. The server ships one admin controller
(`admin/partner-centers`), one center-scoped aggregate (`centers/{id}/dashboard`), and an internal MoR
resolver. The portal's five `/centers/me*` reads do not exist, so all six screens run on client fixtures.

## Screens

All six sit inside `PartnerLayout` (5-tab `MobileShell`, no bell). Every page's access gate is
`useMyPartnerCenter()` — which today resolves a **mock** center for any caller.

| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/partner` | [`PartnerHomeScreen.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/partner/PartnerHomeScreen.tsx) — MoR chip, onboarding banner (`draft`/`pending`/`suspended`), license block (permit · technical-director licence · Enamad · legal type), masked settlement IBAN (MoR only, `:101-103`) |
| 2 | `/fa/partner/nurses` | Sponsored roster — name + verification `StatusChip`, unpaginated (bounded set) |
| 3 | `/fa/partner/bookings` | Read-only sponsored-bookings list; URL-synced status filter over 7 wire codes; translated labels only |
| 4 | `/fa/partner/bookings/[id]` | Scoped detail (REQ-064) — patient display name, date, `StatusTimeline`. **No clinical content, no address, no money** |
| 5 | `/fa/partner/settlement` | MoR drives the whole view: non-MoR → `settlement_not_mor` state; MoR → per-booking commission invoices (`PartnerSettlementRow`), masked IBAN, PDF link, client-side CSV of the loaded page |
| 6 | `/fa/partner/more` | Center identity + MoR chip, appearance/language, sign-out |

`layout.tsx:15` wraps the shell in a `RoleGuard` **with no `expected` role** — partner is deliberately not an
`AppRole`; the guard only hardens `/me` hydration.

## API

Chain traced: page → `useMyPartnerCenter` ([hooks/useMyPartnerCenter.ts:14](../../client/src/services/partnerCenter/hooks/useMyPartnerCenter.ts))
→ `partnerCenterApi` selector (`apis/index.ts`) → **`USE_PARTNER_MOCK = true`**
([constants.ts:10](../../client/src/services/partnerCenter/constants.ts)) → `mockApi.ts:276`
`centerById(MOCK_MY_CENTER_ID = 1)`. The real branch exists but points at routes that 404. Shapes live in
[partner-center.md](../integration/domains/partner-center.md) — not restated here.

| Call | Endpoint | Notes |
| --- | --- | --- |
| Portal: my center / nurses / bookings / booking detail / settlement | `GET /api/v1/centers/me{,/nurses,/bookings,/bookings/{id},/settlement}` | **Phantom — 5 routes.** `clientApi.ts:105-123`. Probed live: `GET /centers/me` as owner `09120000030` → **404** (no such route in swagger; only `/centers/{id}/dashboard` exists) |
| The one real portal read | `GET /api/v1/centers/{id}/dashboard` | **Wired, and it works.** Probed as `09120000030` → **200**: `«مرکز پرستاری آرامش»`, `isMerchantOfRecord true`, `settlementIbanMasked "••••7777"`, `sponsoredNurseCount 1`, `sponsoredBookingCount 2`, `invoiceCount 2`, one inline nurse (`nurseProfileId 2`). **No client code calls it** |
| Tenancy | same | Probed as customer `09120000010` → **403 "This dashboard belongs to another center."** ([GetCenterDashboardQuery.Handler.cs:26-28](../../server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.Handler.cs)) — keyed on `partner_centers.admin_user_id`, **not** a role |
| MoR resolver (HTTP) | `GET /api/v1/internal/bookings/{bookingId}/center` | Exists ([InternalCentersController.cs:23](../../server/src/API/Baya.Web.Api/Controllers/V1/InternalCentersController.cs)) but is `DynamicPermission`. Probed as `super_admin` → **403 "Authorization Error"** — unreachable over HTTP in the demo |
| MoR resolver (in-process) | — | **This is the path that actually runs.** [`IssueInvoiceCommand.Handler.cs:54`](../../server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs) calls `ResolveCenterForBookingAsync`, then sets `IssuingEntityType` (`:63`) and `PartnerCenterId` (`:64`) |
| Admin CRUD / verify / sponsor / set-active | `GET·POST·PATCH /api/v1/admin/partner-centers…` | 7 wired routes, all `DynamicPermission` + sensitive → **403 for every seeded admin** (see [testing-setup.md](testing-setup.md)). Client half is behind the same mock flag |
| Admin roster read | `GET /api/v1/admin/partner-centers/{id}/nurses` | **Phantom** (`clientApi.ts:101`) — the server folds nurses into the dashboard aggregate instead |

**The resolver, traced:** `booking → NurseId → NurseProfile.PartnerCenterId → PartnerCenter.IsMerchantOfRecord`
([PartnerCenterRepository.cs:77-106](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PartnerCenterRepository.cs)).
A non-MoR sponsor does **not** change the issuer — it falls through to `platform`.

**Proof it works on live data:** `GET /api/v1/invoices/6` (booking 6, nurse علی کریمی, the sponsored nurse) as
customer `09120000011` → `"issuingEntityType": "partner_center"`, gross `3,200,000`, commission `480,000`,
VAT `48,000`, total `528,000`. The same call for booking 1 (unsponsored nurse) → `"platform"`. The MoR chain
is real end-to-end **on the server**; only the portal that should surface it is fake.

## Rules that must hold

| Rule | Value / invariant | Source |
| --- | --- | --- |
| Merchant of record | `isMerchantOfRecord = true` → the **center** is the taxable seller and invoice issuer; `false` → sponsor only, platform issues | [business/13 §Decisions](../../product/business/13-tax-invoicing-and-legal.md) |
| Why the vehicle exists | Home nursing is a licensed activity (MoH پروانه تأسیس + مسئول فنی). Partnering with a licensed center is what makes online payment and BNPL legal pre-permit | [business/13](../../product/business/13-tax-invoicing-and-legal.md) |
| VAT | 10% on the **commission only**, additive (`480,000 × 0.10 = 48,000`, confirmed on invoice 6) — never on the nurse's fee | [business/13](../../product/business/13-tax-invoicing-and-legal.md) |
| Commission rate | Per-center override of the platform default; seeded center = `0.05`. Snapshotted at compute time | [integration/partner-center.md](../integration/domains/partner-center.md) |
| Settlement IBAN | Write-then-masked — the full value never comes back on any read (`Mask.IbanTail`, repository `:142`) | [integration/partner-center.md](../integration/domains/partner-center.md) |
| Technical director | `technicalDirectorNurseUserId` links to a real verified nurse, not free text — regulation requires a named مدیر فنی | [integration/partner-center.md](../integration/domains/partner-center.md) |
| `set-active` | Suspend/activate, **not** delete — sponsored nurses and past bookings stay resolvable | [integration/partner-center.md](../integration/domains/partner-center.md) |
| Portal scope | Center admin sees **no clinical content, no address, no customer money** — only patient display name, date, status | `partner/bookings/[id]/page.tsx` |
| `onboardingState` | Client-derived from `isActive` + `verifiedAt` (`deriveCenterState`) — the wire never carries the string | [integration/partner-center.md](../integration/domains/partner-center.md) |

## How to test

1. Log in as **09120000030** (بهنام رستگار, the partner-center owner) — see [testing-setup.md](testing-setup.md).
2. **Expect:** you land on the **customer** home. `/me` returns `roles: ["customer"]` only — there is no
   partner signal (REQ-038), and `services/auth/routing.ts:60` returns `null` for `/partner`, so even
   `?next=/fa/partner` is discarded. Nothing anywhere links to the portal.
3. Type `/fa/partner` in the address bar (locale prefix required).
4. **Expect (this is the tell):** the header reads **«مرکز پرستاری آسان‌گستر»**, permit `MOH-12345`, Enamad
   `EN-999`, IBAN `••••0001` — the **mock fixture** (`mockApi.ts:46-59`), *not* the seeded
   «مرکز پرستاری آرامش» / `MOH-C-1001` / `ENAMAD-DEMO-771` / `••••7777`. PASS for the flow = you see the mock
   center. Seeing the seeded one would mean the flag flipped.
5. `/fa/partner/nurses` → three fixture nurses (زهرا موسوی، مریم رضایی، سارا کاظمی). The seeded reality is
   **one** sponsored nurse, علی کریمی (09120000002).
6. `/fa/partner/bookings` → three rows with ids `5001·5002·5003`; open one. **Expect:** a detail screen that
   resolves — those ids exist in no database. The seeded sponsored bookings are **6 and 5**, both `completed`.
7. `/fa/partner/settlement` → two commission invoices with a fake مودیان reference and a stub PDF. The real
   center's invoices (`invoiceCount 2`) are unreachable from here.
8. **Server-side truth check** (no UI path exists): with the owner's token,
   `GET /api/v1/centers/1/dashboard` → 200 with the seeded center. With a customer's token → 403.
9. **MoR check:** `GET /api/v1/invoices/6` as customer **09120000011** → `issuingEntityType: "partner_center"`;
   `GET /api/v1/invoices/1` as **09120000010** → `"platform"`. This is the only place the MoR rule is
   observable today.

*Seeded-world caveat:* the owner must be told center id `1` out of band — nothing in the API tells a caller
which center they administer. Any admin-side action (create / verify / suspend / sponsor) is **403** for both
demo admin accounts, so the center cannot be administered at all in this build.

## Known gaps

- `USE_PARTNER_MOCK = true` (`partnerCenter/constants.ts:10`) — all 6 portal screens render fixtures; the demo shows a center, nurses, bookings and invoices that exist in no database.
- 5 portal routes are phantom (`GET /centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`, `clientApi.ts:105-123`) — probed 404. REQ-032 / REQ-033 / REQ-064.
- `MOCK_MY_CENTER_ID = 1` (`constants.ts:13`, used at `mockApi.ts:276`) resolves "my center" for **any** signed-in caller — the portal has zero tenancy today, and `layout.tsx:15` passes no `expected` role.
- No partner signal in `/me` (REQ-038): `/fa/partner` is an orphan route reachable only by typing the URL, and `services/auth/routing.ts:60` makes it an invalid `?next=` target.
- Shape mismatch: the server serves **one aggregate** (`centers/{id}/dashboard`, nurses inline and capped by `DashboardNurseCap`); the portal needs `/me` + paginated splits. It cannot page an inline array.
- `GET /centers/{id}/dashboard` requires a center id the portal cannot discover, and **no client code calls it** — the one working portal endpoint is unused.
- The HTTP MoR resolver (`internal/bookings/{id}/center`) is `DynamicPermission` and 403s for both seeded admins — reachable only in-process from `IssueInvoice`.
- All 7 `admin/partner-centers` routes 403 for the seeded `super_admin`/`finance` accounts, and the admin console (`/fa/admin/partners`) is behind the same mock flag — there is **no** working path to create, verify, suspend or sponsor.
- A center owner cannot read the invoices it issues: `GetInvoiceQuery.Handler.cs:25-27` authorizes the `admin` role or the booking's customer only. The settlement view has no legal read even per booking.
- Two MoR resolvers can disagree: BNPL reads the `bnpl_merchant_of_record` config (`platform`) while invoicing uses `ResolveCenterForBookingAsync` — a MoR center's BNPL order and its invoice would name different sellers.
- Seeded invoices carry `moadianStatus: "pending"`, `moadianReferenceNumber: null`, `pdfUrl: null` — the settlement view's PDF/CSV columns have nothing real to render.
- Center self-onboarding is deferred (`product/business/01-actors-and-onboarding.md:24`); the write-then-masked IBAN flow has never been exercised on a real route.
