31 KiB
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, theis_accepting_bookingstoggle, and the guardedis_verifiedflag), 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 (users/auth/OTP/sessions/roles), b0 (
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. 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 extendedusers(phone enc UNIQUE,emailenc nullable,national_idenc nullable,genderNVARCHAR(10)male/female,national_id_verified_at,shahkar_verified_at,role,is_active,deleted_at), builtuser_sessions(refresh-token rotation + reuse detection),roles/user_roles(admin RBAC), the/auth/otp/*,/auth/refresh,/auth/logout,/me, and/me/roleendpoints, and theISmsSenderseam. Read the current user fromICurrentUser; resolve the customer/nurse profile offusers.id— never re-create the user or session machinery.IFieldEncryptor— b0 introduced the field-encryption seam:Encrypt(string)/Decrypt(string)plus a deterministicHash(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 deterministiciban_hashhere). Never store or log plaintext PII.ICurrentUser+ the audit-field SaveChanges interceptor — b0. Handlers never setCreatedById/CreatedAt/ModifiedById/ModifiedAt; the interceptor stamps them fromICurrentUser. UseICurrentUser.UserIdfor tenancy resolution.- The REST surface & CQRS pipeline — b0: versioned
sealed : BaseControllercontrollers,ISender,base.OperationResult(...), snake_case[controller]/[action]routes, rate limiting,LoggingBehavior,ValidateCommandBehavior,OperationResult<T>, Mapster, FluentValidation, CQRS viamartinothamar/Mediator(not MediatR),IDateTimeProvider. platform_configstyped cached accessor — b1. 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.mdand../_shared/backend-conventions-checklist.md— especially Persistence (AsNoTracking +.Selectprojection, pagination, oneIEntityTypeConfiguration<T>per entity, soft-delete filters, the encrypted-PII rule) and CQRS.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); patientgenderis 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— the canonical schema fornurse_profiles,customer_profiles,patients,nurse_bank_accounts. Mirror the field names and constraints exactly: the guardedis_verified, the filtered uniques, theiban_hashUNIQUE, thematched_national_id/account_holder_from_bank/ownership_vendor_reftrio, 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— the four ground truths and the encrypt-PII-at-rest expectation that shapes every column here.- Code to mirror: b2's
users/user_sessionsentity configs and theFeatures/Identity/**(orFeatures/Auth/**) command/handler structure — your new features sit alongside them; b0'sIFieldEncryptorusage and any existing encrypted-column converter pattern; the existingIEntityTypeConfiguration<T>files inPersistence/Configuration/for the filtered-index and value-converter syntax. - Contract conventions:
../../contracts/conventions/api-conventions.md(envelope, snake_case routes, pagination, auth) and../../contracts/conventions/money-and-types.md(the PII & sensitive fields masking rule — IBAN returned masked, last-4 only — andgenderas load-bearing). - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-2.md,…-after-backend-phase-0.md, andreports/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}/<Name>/; entities under
Baya.Domain/Entities/Identity/; one IEntityTypeConfiguration<T> 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 →usersUNIQUE, 1:1);partner_center_id(FK →partner_centersNULL — 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 aggregatesaverage_rating,total_reviews,total_completed_bookings;created_at,updated_at,deleted_at(soft-delete). is_verifiedis write-guarded — model it with no public setter (private/internal-set, or aMarkVerified()/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_bookingsdefault 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.statusfrom 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 →usersUNIQUE, 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'scustomer_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) — deterministicIFieldEncryptor.Hash(iban),UNIQUEso 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 ontruein 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 →usersNULL);verified_at(NULL);created_at,updated_at. - Constraints:
UNIQUE(iban_hash); filteredUNIQUE(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 b6bank_account_verificationstep 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— eachsealed : BaseController, injectISender, returnbase.OperationResult(...), snake_case[controller]/[action]routes,CancellationTokenthreaded. 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 —
genderrequired + in (male,female) onCreatePatientCommand; IBAN format (IR + 24 digits, Sheba) onAddNurseBankAccountCommand; 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+ nursenurse_service_areas— need the province/city/district hierarchy and the geocoder. DEFERRED to b4. 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 perproduct/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_rolesmanagement UI) — the tables exist from b2; the admin management endpoints land with the admin backoffice in b15. 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_verifiedflip — 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
(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. This phase owns onlyIBankAccountOwnershipVerifier.
5. Critical rules you must not get wrong
is_verifiedis write-guarded — never expose a setter. Modelnurse_profiles.is_verifiedwith no public setter (private-set + a domain method the verification transaction calls). It is flipped ONLY inside the b6 verification-confirm transaction once every requiredverification_steps.status='passed'. No command, controller, mapping, or update in this phase may set it; a profile is created withis_verified = 0and 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_idfromICurrentUser, never from the request body. Every patient read/write is scoped to the signed-in customer; reading or mutating another customer's patient returnsNotFoundResult(don't leak existence). A patient used in a booking must belong to the samecustomer_id(fully enforced in b8, scoping begins here). The same ownership scoping applies tonurse_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, andinitial_medical_notesroute throughIFieldEncryptor. 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_hashis UNIQUE — one IBAN can't serve two nurses. Compute it deterministically viaIFieldEncryptor.Hash(iban)and rely on theUNIQUE(iban_hash)index as the authoritative duplicate guard. A duplicate add is a clean409/FailureResult, not an unhandledDbUpdateException. (The encryptedibancolumn 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 = 1index is the backstop;SetPrimaryBankAccountCommandclears the old primary and sets the new one in one transaction so the constraint never trips. Never let twois_primary = 1rows 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. 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_idstarts NULL and only the inquiry sets it. - Customer national-ID KYC is DEFERRED — do not create
national_id_verified_atoncustomer_profiles, do not collect it, and never gate customer browsing or booking on it. - Aggregates and
is_verifiedare read-only inputs to this phase.average_rating,total_reviews,total_completed_bookingsdefault to 0 and are written only by later phases; reject any attempt to set them (oris_verified) from a request body. - Reads are projected + paginated; money/PII never leaks. AsNoTracking +
.Selectto a DTO on every read;ListPatientsQuery/ListNurseBankAccountsQuerypaginate; no unboundedToListAsync(). Audit fields are stamped by the interceptor, not the handler.
6. Definition of Done
The shared definition-of-done.md, plus:
- The four tables (
nurse_profiles,customer_profiles,patients,nurse_bank_accounts) exist via one migration, each with itsIEntityTypeConfiguration<T>: the 1:1 UNIQUE onnurse_profiles.user_idandcustomer_profiles.user_id, theUNIQUE(iban_hash), the filteredUNIQUE(nurse_id) WHERE is_primary=1, the guarded (no-public-setter)is_verified, encrypted PII columns, soft-delete filter onnurse_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. IBankAccountOwnershipVerifierintroduced (Application interface, Infrastructure mock, DI registration via aServiceConfiguration/extension, config-selected). Noif (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_primaryflips the filtered-unique correctly; duplicate-IBAN rejected viaiban_hash. ≥1WebApplicationFactoryintegration test per controller (happy path, 401, validation 400).dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen. - The
Baya.Application/Features/Identity/**profile/patient/bank areas and theIBankAccountOwnershipVerifierseam are reflected in the Project map inserver/CLAUDE.md. - The contract
dev/contracts/domains/identity-profiles.mdis written and theswagger.jsonsnapshot 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:
- Nurse profile —
POST api/v1/nurse_profiles/upsertwith bio/experience → anurse_profilesrow is created withis_verified = 0andis_accepting_bookings = 0;GET api/v1/nurse_profiles/mereturns it with read-only aggregates at 0. Confirm there is no endpoint or body field that can setis_verified. - Accepting-bookings toggle —
POST api/v1/nurse_profiles/set_accepting_bookings→ flipsis_accepting_bookings;is_verifiedis untouched. - Customer profile —
POST api/v1/customer_profiles/upsertwith an emergency contact → row created;GET …/mereturns it; verify the stored emergency-contact phone is encrypted at rest (inspect the column — it is ciphertext, not the plaintext number). - Patient CRUD —
POST api/v1/patients/create(with requiredgender) → patient created under the signed-in customer;GET api/v1/patients/listshows it;update/archivework;initial_medical_notesis ciphertext at rest. - Tenancy rejection — as customer A, create a patient; as customer B, call
GET api/v1/patients/get/{A's id}andPOST 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. - Add bank account + ownership inquiry —
POST api/v1/nurse_bank_accounts/addwith a normal test IBAN → account created; the mockIBankAccountOwnershipVerifierruns and setsmatched_national_id = true,account_holder_from_bank, andownership_vendor_ref;GET …/listshows the IBAN masked (last 4). - Ownership mismatch — add an account with the designated mismatch test IBAN →
matched_national_id = falseand the mismatch holder name is recorded (the payout-gating path can later reject it). - Duplicate IBAN — add the same IBAN again (same nurse or a second nurse) → rejected with a clean
409/failure via theiban_hashUNIQUE, not an unhandled exception. - 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 twois_primary = 1rows 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(add theFeatures/Identity/**profile/patient/bank areas + theIBankAccountOwnershipVerifierseam 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 inproduct/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) — the nurse-profile, customer-profile, patient, and nurse-bank-account endpoints; thegender(male/female) andblood_typeenums; the profile/patient/bank-account DTO shapes (read-onlyis_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, andmatched_national_id-gates-first-payout side-effects. Republish theswagger.jsonsnapshot per../../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 behindIBankAccountOwnershipVerifier), append tobackend/STATUS.md, writedev/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), theis_verifiedflip (b6), payout gating onmatched_national_id(b13), aggregate recompute (b9/b14)), and updatedev/shared-working-context/reports/mocks-registry.md(theIBankAccountOwnershipVerifierrow → 🟡). - Memory: save a
projectmemory note for the non-obvious decisions this phase fixes — the guardedis_verified(no setter, flipped only in b6), the patient tenancy invariant + cross-customer-returns-404 rule, theiban_hashdeterministic-hash uniqueness, the filtered single-primary index, and thematched_national_id-gates-first-payout link to b13 — with a one-line pointer inMEMORY.md.