12 KiB
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 Integration: docs/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 — 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)
→ partnerCenterApi selector (apis/index.ts) → USE_PARTNER_MOCK = true
(constants.ts:10) → 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 — 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) — keyed on partner_centers.admin_user_id, not a role |
| MoR resolver (HTTP) | GET /api/v1/internal/bookings/{bookingId}/center |
Exists (InternalCentersController.cs:23) 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 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). 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).
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 |
| 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 |
| 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 |
| Commission rate | Per-center override of the platform default; seeded center = 0.05. Snapshotted at compute time |
integration/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 |
| Technical director | technicalDirectorNurseUserId links to a real verified nurse, not free text — regulation requires a named مدیر فنی |
integration/partner-center.md |
set-active |
Suspend/activate, not delete — sponsored nurses and past bookings stay resolvable | integration/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 |
How to test
- Log in as 09120000030 (بهنام رستگار, the partner-center owner) — see testing-setup.md.
- Expect: you land on the customer home.
/mereturnsroles: ["customer"]only — there is no partner signal (REQ-038), andservices/auth/routing.ts:60returnsnullfor/partner, so even?next=/fa/partneris discarded. Nothing anywhere links to the portal. - Type
/fa/partnerin the address bar (locale prefix required). - Expect (this is the tell): the header reads «مرکز پرستاری آسانگستر», permit
MOH-12345, EnamadEN-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. /fa/partner/nurses→ three fixture nurses (زهرا موسوی، مریم رضایی، سارا کاظمی). The seeded reality is one sponsored nurse, علی کریمی (09120000002)./fa/partner/bookings→ three rows with ids5001·5002·5003; open one. Expect: a detail screen that resolves — those ids exist in no database. The seeded sponsored bookings are 6 and 5, bothcompleted./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.- 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. - MoR check:
GET /api/v1/invoices/6as customer 09120000011 →issuingEntityType: "partner_center";GET /api/v1/invoices/1as 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 atmockApi.ts:276) resolves "my center" for any signed-in caller — the portal has zero tenancy today, andlayout.tsx:15passes noexpectedrole.- No partner signal in
/me(REQ-038):/fa/partneris an orphan route reachable only by typing the URL, andservices/auth/routing.ts:60makes it an invalid?next=target. - Shape mismatch: the server serves one aggregate (
centers/{id}/dashboard, nurses inline and capped byDashboardNurseCap); the portal needs/me+ paginated splits. It cannot page an inline array. GET /centers/{id}/dashboardrequires 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) isDynamicPermissionand 403s for both seeded admins — reachable only in-process fromIssueInvoice. - All 7
admin/partner-centersroutes 403 for the seededsuper_admin/financeaccounts, 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-27authorizes theadminrole 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_recordconfig (platform) while invoicing usesResolveCenterForBookingAsync— 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.