# Backend Phase 3 — Identity: profiles, patients & nurse bank accounts > **Mission:** put the *people* behind the accounts. On top of the bare `users`/sessions/auth spine that > b2 shipped, build the role-specific profile extensions a marketplace actually transacts against: a > nurse's seller profile (bio, experience, the `is_accepting_bookings` toggle, and the **guarded** > `is_verified` flag), a customer's thin payer profile with its emergency contact, the first-class > **patient** records a customer manages on behalf of the people who can't self-advocate, and the > **payout bank account** that is the single place real money will one day leave the platform. Every PII > column here is encrypted; tenancy is enforced so a customer can only ever touch their own patients; and > the IBAN is hardened with a uniqueness hash and an automated **استعلام شبا** ownership inquiry — not an > admin's eyeballs. This is the data catalog, verification, booking, and payouts all build directly on. > > **Track:** backend · **Depends on:** [b2](./backend-phase-2.md) (users/auth/OTP/sessions/roles), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST surface, seam pattern) · **Unlocks:** geography & addresses (b4), catalog (b5), nurse verification (b6), booking (b8); frontend **f2-b3** > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits This is **backend phase b3**, the second identity phase. b2 turned the inherited auth spine into a real phone-OTP login: `users` (phone primary, `gender`, nullable `national_id`), `user_sessions` (rotation + reuse detection), `roles`/`user_roles`, and the role-selection flow. What b2 deliberately did **not** build is everything *attached* to a user once they pick a role — the seller profile, the payer profile, the care recipients, and the payout destination. That is this phase. After b3, a nurse has a profile that verification (b6) can flip to verified and that the catalog (b5) can hang priced service variants off; a customer has patients and a profile that booking (b8) can act on behalf of; and a nurse has a bank account that payouts (b13) can pay into. **No geography here** — saved service addresses (`customer_addresses`) and nurse coverage areas need the province/city/district tables, so they belong to **b4** (see §3 DEFERRED). **What already exists (do not rebuild) — built by prior phases:** - **`users`, auth, sessions, roles** — [b2](./backend-phase-2.md) extended `users` (phone enc UNIQUE, `email` enc nullable, `national_id` enc nullable, `gender` NVARCHAR(10) `male`/`female`, `national_id_verified_at`, `shahkar_verified_at`, `role`, `is_active`, `deleted_at`), built `user_sessions` (refresh-token rotation + reuse detection), `roles`/`user_roles` (admin RBAC), the `/auth/otp/*`, `/auth/refresh`, `/auth/logout`, `/me`, and `/me/role` endpoints, and the `ISmsSender` seam. **Read the current user from `ICurrentUser`; resolve the customer/nurse profile off `users.id` — never re-create the user or session machinery.** - **`IFieldEncryptor`** — [b0](./backend-phase-0.md) introduced the field-encryption seam: `Encrypt(string)` / `Decrypt(string)` plus a deterministic `Hash(string)` for lookup columns. **Every PII column in this phase goes through it** (national_id was wired by b2; you add IBAN, account-holder name, emergency contacts, medical notes, and the deterministic `iban_hash` here). Never store or log plaintext PII. - **`ICurrentUser` + the audit-field SaveChanges interceptor** — [b0](./backend-phase-0.md). Handlers never set `CreatedById`/`CreatedAt`/`ModifiedById`/`ModifiedAt`; the interceptor stamps them from `ICurrentUser`. Use `ICurrentUser.UserId` for tenancy resolution. - **The REST surface & CQRS pipeline** — [b0](./backend-phase-0.md): versioned `sealed : BaseController` controllers, `ISender`, `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, rate limiting, `LoggingBehavior`, `ValidateCommandBehavior`, `OperationResult`, Mapster, FluentValidation, CQRS via **`martinothamar/Mediator`** (not MediatR), `IDateTimeProvider`. - **`platform_configs` typed cached accessor** — [b1](./backend-phase-1.md). If you need a tunable (e.g. a max-patients-per-customer guard), read it through the accessor; never hardcode. **What this phase introduces:** the four new domain tables (`nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`), their CRUD/management capabilities, and **one new seam — `IBankAccountOwnershipVerifier`** (the mocked استعلام شبا IBAN-owner ↔ national-id inquiry). The `partner_center_id` FK on `nurse_profiles` is a **forward dependency** on b15 — declare it nullable and do not enforce or build `partner_centers` here. ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially *Persistence* (AsNoTracking + `.Select` projection, pagination, one `IEntityTypeConfiguration` per entity, soft-delete filters, the encrypted-PII rule) and *CQRS*. - [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) — **the business rules**: phone is the primary credential; the **customer ≠ patient** split (the payer is frequently not the care recipient); patient `gender` is load-bearing for same-gender matching; customer national-ID KYC is **DEFERRED**; a nurse is not bookable until verified. - [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) — **the canonical schema** for `nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`. Mirror the field names and constraints exactly: the guarded `is_verified`, the filtered uniques, the `iban_hash` UNIQUE, the `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref` trio, and the **CUT** columns (`nurse_profiles.verification_status`, `response_rate`, `avg_response_time_hours`, `profile_completion_score`; `customer_profiles.national_id_verified_at`) — **do not reintroduce them.** - [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four ground truths and the encrypt-PII-at-rest expectation that shapes every column here. - **Code to mirror:** b2's `users`/`user_sessions` entity configs and the `Features/Identity/**` (or `Features/Auth/**`) command/handler structure — your new features sit alongside them; b0's `IFieldEncryptor` usage and any existing encrypted-column converter pattern; the existing `IEntityTypeConfiguration` files in `Persistence/Configuration/` for the filtered-index and value-converter syntax. - **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case routes, pagination, auth) and [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the **PII & sensitive fields** masking rule — IBAN returned masked, last-4 only — and `gender` as load-bearing). - **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-2.md`, `…-after-backend-phase-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse + the new one you add). ## 3. Scope — build this All features live under `Baya.Application/Features/Identity/{Commands|Queries}//`; entities under `Baya.Domain/Entities/Identity/`; one `IEntityTypeConfiguration` per entity in `Persistence/Configuration/IdentityConfig/`; **one EF migration** for the four new tables. Reads use `AsNoTracking()` + `.Select(...)` to a DTO and are paginated where they list; writes go through `IUnitOfWork` with a single `CommitAsync` per command and never throw for expected failures (return `OperationResult.SuccessResult/FailureResult/NotFoundResult`). Encrypted columns route through `IFieldEncryptor` (an EF value converter or in-handler encrypt — mirror b2's national_id approach). ### 3.1 Entities + migration **`nurse_profiles`** [CORE] — the nurse's seller profile + denormalized search/quality aggregates. - Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `partner_center_id` (FK → `partner_centers` **NULL** — forward dependency on b15, nullable, **no FK enforcement target built here**); `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`; **`is_verified`** (BIT NOT NULL DEFAULT 0 — **GUARDED**, see §5); `is_accepting_bookings` (BIT NOT NULL DEFAULT 0); denormalized read-only aggregates `average_rating`, `total_reviews`, `total_completed_bookings`; `created_at`, `updated_at`, `deleted_at` (soft-delete). - **`is_verified` is write-guarded** — model it with **no public setter** (private/internal-set, or a `MarkVerified()`/`MarkUnverified()` domain method only the b6 verification-confirm transaction calls). No command, controller, or mapping in *this* phase may set it. See §5. - **Aggregates are read-only here** — `average_rating`/`total_reviews`/`total_completed_bookings` default to 0 and are recomputed by reviews/bookings phases; never accept them from a request body. - **Do NOT add** `verification_status`, `response_rate`, `avg_response_time_hours`, `profile_completion_score` (CUT — `nurse_verifications.status` from b6 is the sole verification truth). - Soft-delete global query filter on `deleted_at IS NULL`. - Relations: 1:1 → `users`; (forward) 1:N → `nurse_service_variants` (b5), `nurse_service_areas` (b4), `nurse_bank_accounts` (this phase), `nurse_verifications` (b6), `bookings`/`nurse_payouts` (later). **`customer_profiles`** [CORE] — the thin payer extension. - Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `default_emergency_contact_name` (**enc**); `default_emergency_contact_phone` (**enc**); `created_at`, `updated_at`. - **Do NOT add** `national_id_verified_at` (CUT for MVP — customer national-ID KYC is DEFERRED; the column is not even created at launch). - Relations: 1:1 → `users`; 1:N → `patients` (this phase), `customer_addresses` (b4), `booking_requests` (b8). **`patients`** [CORE] — the care recipient, a first-class entity separate from the payer. - Fields: `id` (BIGINT PK); `customer_id` (FK → `customer_profiles`); `display_name`, `first_name`, `last_name`; `birth_date` (DATE); `gender` (NVARCHAR(10) — `male`/`female`, **required**, same-gender-matching signal); `blood_type` (nullable); `initial_medical_notes` (**enc**); `is_active` (BIT, archive flag); `created_at`, `updated_at`. - **Tenancy invariant** (see §5): a patient belongs to exactly one `customer_id`; every read/write is scoped to the signed-in customer; a patient referenced by a booking must belong to that booking's `customer_id` (enforced fully in b8, but the ownership scoping starts here). - Relations: N:1 → `customer_profiles`; (forward) 1:N → `booking_requests` (b8), `patient_care_records` (b14). **`nurse_bank_accounts`** [CORE] — the payout destination; the single place real money leaves the platform. - Fields: `id` (BIGINT PK); `nurse_id` (FK → `nurse_profiles`); `bank_name`; `account_holder_name` (**enc**); `iban` (**enc**); **`iban_hash`** (NVARCHAR(64) — deterministic `IFieldEncryptor.Hash(iban)`, **`UNIQUE`** so one IBAN can't silently serve two nurses); `is_primary` (BIT); **`matched_national_id`** (BIT **NULL** — result of the استعلام شبا IBAN-owner ↔ national-id inquiry; NULL until the inquiry runs; first payout is gated on `true` in b13); `account_holder_from_bank` (NVARCHAR(200) NULL — name the bank returned, snapshot); `ownership_vendor_ref` (NVARCHAR(200) NULL — vendor transaction id for audit); `is_verified` (BIT); `verified_by_admin_id` (FK → `users` NULL); `verified_at` (NULL); `created_at`, `updated_at`. - **Constraints:** `UNIQUE(iban_hash)`; **filtered `UNIQUE(nurse_id) WHERE is_primary = 1`** (exactly one primary account per nurse). Both are DB indexes in the migration — the authoritative backstop, not just handler logic. - Relations: N:1 → `nurse_profiles`; (forward) 1:N → `nurse_payouts` (b13). The b6 `bank_account_verification` step couples to this table — build the account + inquiry here, the verification step there. ### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures) | Capability | Type | Route | What it does | | --- | --- | --- | --- | | **`UpsertNurseProfileCommand`** | Command | `POST api/v1/nurse_profiles/upsert` | Creates (on first call) or updates the signed-in nurse's profile: `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`. Resolves `user_id` from `ICurrentUser`; rejects if the user's role isn't `nurse`. **Never** accepts `is_verified` or the aggregates. Idempotent on `user_id` (the UNIQUE 1:1 is the backstop). | | **`SetNurseAcceptingBookingsCommand`** | Command | `POST api/v1/nurse_profiles/set_accepting_bookings` | Toggles `is_accepting_bookings` for the signed-in nurse (pause/resume without touching verified status). | | **`GetMyNurseProfileQuery`** | Query | `GET api/v1/nurse_profiles/me` | Projects the signed-in nurse's profile incl. read-only `is_verified` + aggregates. AsNoTracking + `.Select`. | | **`UpsertCustomerProfileCommand`** | Command | `POST api/v1/customer_profiles/upsert` | Creates/updates the signed-in customer's profile + `default_emergency_contact_name`/`_phone` (**encrypted**). Resolves `user_id` from `ICurrentUser`; rejects non-`customer` role. | | **`GetMyCustomerProfileQuery`** | Query | `GET api/v1/customer_profiles/me` | Projects the customer's profile; emergency-contact phone returned **masked** to non-self callers per the masking rule (self sees full). | | **`CreatePatientCommand`** | Command | `POST api/v1/patients/create` | Creates a patient under the signed-in customer (`customer_id` derived from `ICurrentUser` → `customer_profiles`, **never** from the request). Validates required `gender`, name, `birth_date`; `initial_medical_notes` encrypted. | | **`ListPatientsQuery`** | Query | `GET api/v1/patients/list?page=&page_size=` | Lists the signed-in customer's **own** patients only (tenancy-scoped). Projected + paginated; `initial_medical_notes` returned to the owning customer only. | | **`GetPatientQuery`** | Query | `GET api/v1/patients/get/{id}` | Returns one patient **only if it belongs to the signed-in customer** — otherwise `NotFoundResult` (don't leak existence). | | **`UpdatePatientCommand`** | Command | `POST api/v1/patients/update/{id}` | Updates a patient the signed-in customer owns; tenancy-checked; re-encrypts changed PII. | | **`ArchivePatientCommand`** | Command | `POST api/v1/patients/archive/{id}` | Sets `is_active = false` (soft archive, not hard delete — preserves longitudinal history for b14). Tenancy-checked. | | **`AddNurseBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/add` | Adds a payout account for the signed-in nurse: `bank_name`, `account_holder_name` (enc), `iban` (enc) + computed `iban_hash`. **Rejects a duplicate IBAN** via the `iban_hash` UNIQUE (return a clean `409`/`FailureResult`, not an unhandled DB exception). Then **triggers `IBankAccountOwnershipVerifier`** (see §4) and persists `matched_national_id`, `account_holder_from_bank`, `ownership_vendor_ref`. If the nurse has no other account, this one may default to `is_primary` (subject to the filtered-unique rule). | | **`SetPrimaryBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/set_primary/{id}` | Makes one of the nurse's accounts primary: clears the prior primary and sets this one, in **one transaction**, so the filtered `UNIQUE(nurse_id) WHERE is_primary=1` never trips. A duplicate-primary attempt without clearing the old one must be blocked (the filtered unique is the backstop). Tenancy-checked. | | **`ListNurseBankAccountsQuery`** | Query | `GET api/v1/nurse_bank_accounts/list` | Lists the signed-in nurse's accounts with **masked IBAN** (last 4 only), `is_primary`, `is_verified`, `matched_national_id`. Projected. | | **`TriggerBankAccountOwnershipInquiryCommand`** | Command | `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` | Re-runs the استعلام شبا inquiry for an existing account (e.g. after a failed/NULL first attempt) and updates `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref`. Idempotent — re-running with the same input yields the same vendor ref from the mock. | - **Controllers:** `NurseProfilesController`, `CustomerProfilesController`, `PatientsController`, `NurseBankAccountsController` — each `sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded. Authorize with the narrowest fitting policy: nurse-scoped endpoints require the nurse role, customer-scoped the customer role; the bank-account ownership-inquiry endpoints are **rate-limited** (they call a vendor seam). - **Validators:** FluentValidation on every input-bearing command — `gender` required + in (`male`,`female`) on `CreatePatientCommand`; IBAN format (IR + 24 digits, Sheba) on `AddNurseBankAccountCommand`; non-empty names; phone format on the emergency contact. - **Mapping:** Mapster in the handler *after* the projected query — never hydrate an entity to map it. ### 3.3 DEFERRED (build the seam/flag, not the feature — with a pointer) - **`customer_addresses`** + nurse **`nurse_service_areas`** — need the province/city/district hierarchy and the geocoder. **DEFERRED to [b4](./backend-phase-4.md).** Do not create these tables or their CRUD here; the digest is explicit that addresses need geography first. - **Customer national-ID KYC** (`customer_profiles.national_id_verified_at`) — DEFERRED per [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) §(c). The column is **not created** at MVP; do not collect or gate browsing/booking on it. - **Admin staff-role grant/revoke** (`roles`/`user_roles` management UI) — the tables exist from b2; the admin management endpoints land with the admin backoffice in [b15](./backend-phase-15.md). Don't build them here. - **Nurse aggregate recompute** (writing `average_rating`/`total_reviews`/`total_completed_bookings`) — the *columns* are created here read-only; the recompute writes come from reviews/bookings phases (b9/b14). - **`is_verified` flip** — the *guarded model* is built here; the flip itself is the b6 verification-confirm transaction. Expose **no** write path for it in this phase. ## 4. Mocks & seams in this phase | Seam | Owner | Mock behaviour | Registry | | --- | --- | --- | --- | | **`IBankAccountOwnershipVerifier`** | **introduced here** | `VerifyOwnershipAsync(iban, nurseNationalId, ct)` returns an `OwnershipInquiryResult` with `MatchedNationalId` (bool), `AccountHolderFromBank` (string), `VendorRef` (string). Mock = **deterministic fake match**: for a normal seeded/test IBAN it returns `MatchedNationalId = true`, echoes a plausible `AccountHolderFromBank` (e.g. the submitted holder name), and a fake `VendorRef` (e.g. `MOCK-SHEBA-{hash}`); for a designated **mismatch** test IBAN it returns `MatchedNationalId = false` (and a different holder name) so the ownership-mismatch path is testable. **No real bank/KYC call, no money moves.** | **add a new row** (🟡) | | `IFieldEncryptor` | reuse from **b0** | local symmetric key from config; encrypts `iban`, `account_holder_name`, `initial_medical_notes`, emergency contacts, and computes the deterministic `iban_hash` via `Hash(iban)`. Never logs plaintext. | reuse row | | `ICurrentUser` | reuse from **b0** | resolves `UserId` for tenancy scoping. | reuse row | | `IDateTimeProvider` | reuse from **b0** | testable `UtcNow` for `verified_at`/audit. | reuse row | The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later); selection is **config-driven, never an `if (mock)` branch** in a handler. Append the `IBankAccountOwnershipVerifier` row to [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file, what's faked, config keys, **step-by-step how to make it real** — pick a Finnotech/banking-bridge استعلام شبا provider, add its client + package + settings, implement `VerifyOwnershipAsync` against the real Sheba-owner inquiry, persist the real `ownership_vendor_ref`/`external_response_json`, and what to test). > Do **not** pre-build the verification-pipeline seams (`IShahkarVerifier`, `IIdentityKycProvider`, > `ICredentialVerifier`) — those are introduced in [b6](./backend-phase-6.md). This phase owns only > `IBankAccountOwnershipVerifier`. ## 5. Critical rules you must not get wrong - **`is_verified` is write-guarded — never expose a setter.** Model `nurse_profiles.is_verified` with no public setter (private-set + a domain method the verification transaction calls). It is flipped **ONLY** inside the b6 verification-confirm transaction once every required `verification_steps.status='passed'`. No command, controller, mapping, or update in *this* phase may set it; a profile is created with `is_verified = 0` and stays there until b6. A nurse is **not bookable** until verified — onboarding a profile must never imply bookability. - **Tenancy invariant — a patient belongs to its customer, always.** Resolve `customer_id` from `ICurrentUser`, **never** from the request body. Every patient read/write is scoped to the signed-in customer; reading or mutating another customer's patient returns `NotFoundResult` (don't leak existence). A patient used in a booking must belong to the same `customer_id` (fully enforced in b8, scoping begins here). The same ownership scoping applies to `nurse_bank_accounts` (a nurse only ever touches their own). - **Encrypt all PII at rest.** `national_id` (already, from b2), `iban`, `account_holder_name`, `default_emergency_contact_name`, `default_emergency_contact_phone`, and `initial_medical_notes` route through `IFieldEncryptor`. Never store or log plaintext; never project plaintext PII into a non-authorized response. On the wire, IBAN is **masked** (last 4) in list responses per the masking rule. - **`iban_hash` is UNIQUE — one IBAN can't serve two nurses.** Compute it deterministically via `IFieldEncryptor.Hash(iban)` and rely on the `UNIQUE(iban_hash)` index as the authoritative duplicate guard. A duplicate add is a **clean** `409`/`FailureResult`, not an unhandled `DbUpdateException`. (The encrypted `iban` column itself is non-deterministic and can't be uniquely indexed — that's *why* the hash exists.) - **Exactly one primary bank account per nurse.** The filtered `UNIQUE(nurse_id) WHERE is_primary = 1` index is the backstop; `SetPrimaryBankAccountCommand` clears the old primary and sets the new one in **one transaction** so the constraint never trips. Never let two `is_primary = 1` rows exist for a nurse. - **First payout is gated on `matched_national_id = true`** — set here by the استعلام شبا inquiry (`IBankAccountOwnershipVerifier`), enforced at payout time in [b13](./backend-phase-13.md). Never mark a payout-ready state off admin eyeballing; the holder-national-id ↔ verified-nurse-national-id match (money-mule prevention) is the gate. `matched_national_id` starts NULL and only the inquiry sets it. - **Customer national-ID KYC is DEFERRED** — do not create `national_id_verified_at` on `customer_profiles`, do not collect it, and **never gate customer browsing or booking on it.** - **Aggregates and `is_verified` are read-only inputs to this phase.** `average_rating`, `total_reviews`, `total_completed_bookings` default to 0 and are written only by later phases; reject any attempt to set them (or `is_verified`) from a request body. - **Reads are projected + paginated; money/PII never leaks.** AsNoTracking + `.Select` to a DTO on every read; `ListPatientsQuery`/`ListNurseBankAccountsQuery` paginate; no unbounded `ToListAsync()`. Audit fields are stamped by the interceptor, not the handler. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] The four tables (`nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`) exist via **one migration**, each with its `IEntityTypeConfiguration`: the 1:1 UNIQUE on `nurse_profiles.user_id` and `customer_profiles.user_id`, the `UNIQUE(iban_hash)`, the filtered `UNIQUE(nurse_id) WHERE is_primary=1`, the guarded (no-public-setter) `is_verified`, encrypted PII columns, soft-delete filter on `nurse_profiles`, and audit wiring. **None** of the CUT columns are present. - [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads, FluentValidation validators), with the four controllers, each role-scoped and tenancy-enforced. - [ ] **`IBankAccountOwnershipVerifier`** introduced (Application interface, Infrastructure mock, DI registration via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. - [ ] Tenancy is provably enforced (a customer cannot read/mutate another customer's patient; a nurse cannot touch another nurse's bank account); duplicate-IBAN and duplicate-primary are blocked by the DB indexes and surfaced as clean failures. - [ ] Handler unit tests (NSubstitute) for: nurse/customer profile upsert; patient CRUD + the **cross-customer rejection**; bank-account add → ownership-inquiry sets `matched_national_id`; the mismatch-IBAN path; `set_primary` flips the filtered-unique correctly; duplicate-IBAN rejected via `iban_hash`. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401, validation 400). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green. - [ ] The `Baya.Application/Features/Identity/**` profile/patient/bank areas and the `IBankAccountOwnershipVerifier` seam are reflected in the **Project map** in `server/CLAUDE.md`. - [ ] The contract `dev/contracts/domains/identity-profiles.md` is written and the `swagger.json` snapshot republished. ## 7. How to test (what a human can verify after this phase) Log in as a nurse and as a customer (reuse b2's OTP flow / a seeded session). Then: 1. **Nurse profile** — `POST api/v1/nurse_profiles/upsert` with bio/experience → a `nurse_profiles` row is created with `is_verified = 0` and `is_accepting_bookings = 0`; `GET api/v1/nurse_profiles/me` returns it with read-only aggregates at 0. Confirm there is **no** endpoint or body field that can set `is_verified`. 2. **Accepting-bookings toggle** — `POST api/v1/nurse_profiles/set_accepting_bookings` → flips `is_accepting_bookings`; `is_verified` is untouched. 3. **Customer profile** — `POST api/v1/customer_profiles/upsert` with an emergency contact → row created; `GET …/me` returns it; verify the stored emergency-contact phone is **encrypted at rest** (inspect the column — it is ciphertext, not the plaintext number). 4. **Patient CRUD** — `POST api/v1/patients/create` (with required `gender`) → patient created under the signed-in customer; `GET api/v1/patients/list` shows it; `update`/`archive` work; `initial_medical_notes` is ciphertext at rest. 5. **Tenancy rejection** — as customer **A**, create a patient; as customer **B**, call `GET api/v1/patients/get/{A's id}` and `POST api/v1/patients/update/{A's id}` → both return **404/not-found** (B can never see or mutate A's patient). This is the load-bearing tenancy test. 6. **Add bank account + ownership inquiry** — `POST api/v1/nurse_bank_accounts/add` with a normal test IBAN → account created; the mock `IBankAccountOwnershipVerifier` runs and sets `matched_national_id = true`, `account_holder_from_bank`, and `ownership_vendor_ref`; `GET …/list` shows the IBAN **masked** (last 4). 7. **Ownership mismatch** — add an account with the designated **mismatch** test IBAN → `matched_national_id = false` and the mismatch holder name is recorded (the payout-gating path can later reject it). 8. **Duplicate IBAN** — add the **same** IBAN again (same nurse or a second nurse) → rejected with a clean `409`/failure via the `iban_hash` UNIQUE, **not** an unhandled exception. 9. **Primary flip** — add a second account and `POST api/v1/nurse_bank_accounts/set_primary/{id2}` → account 2 becomes primary and account 1 is no longer primary; at no point do two `is_primary = 1` rows exist (the filtered unique holds). Attempting to force a second primary without clearing the first is blocked. ## 8. Hand off & document (close the phase) - **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the `Features/Identity/**` profile/patient/bank areas + the `IBankAccountOwnershipVerifier` seam and where it's registered). If you discover/confirm a rule the product docs don't capture (e.g. the masked-IBAN-on-list behaviour, or the default-first-account-becomes-primary behaviour), record it in [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) — don't invent rules. - **Contract to write:** **`dev/contracts/domains/identity-profiles.md`** (per [`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse-profile, customer-profile, patient, and nurse-bank-account endpoints; the `gender` (`male`/`female`) and `blood_type` enums; the profile/patient/bank-account DTO shapes (read-only `is_verified` + aggregates; **masked** IBAN; encrypted fields stated as masked vs full); auth/role/rate-limit notes; and the tenancy, guarded-`is_verified`, `iban_hash`-uniqueness, single-primary, and `matched_national_id`-gates-first-payout side-effects. Republish the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f2-b3** consumes. - **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (profiles, patients, and nurse bank accounts are live; what **f2-b3** can now build — "who is care for"/patient add-list-edit, customer profile, nurse profile bootstrap, nurse bank-account settings; which endpoints/contracts are live; that addresses/service-areas are **DEFERRED to b4**; that the IBAN-ownership rail is mocked behind `IBankAccountOwnershipVerifier`), append to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-3-report.md` (what was built, **what is now testable and exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: addresses (b4), the `is_verified` flip (b6), payout gating on `matched_national_id` (b13), aggregate recompute (b9/b14)), and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankAccountOwnershipVerifier` row → 🟡). - **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the guarded `is_verified` (no setter, flipped only in b6), the patient tenancy invariant + cross-customer-returns-404 rule, the `iban_hash` deterministic-hash uniqueness, the filtered single-primary index, and the `matched_national_id`-gates-first-payout link to b13 — with a one-line pointer in `MEMORY.md`.