Files
baya-monorepo/archive/docs/flows/partner-center.md
T
2026-08-02 20:01:31 +03:30

120 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.