# Frontend → Backend requests (append-only)

The frontend lane appends here when it needs a contract that doesn't exist yet, finds a shape mismatch,
or needs a new field/filter/endpoint. The backend agent reads this at the start of each phase and
delivers fixes in its own change. **Frontend never edits backend code to "fix" a gap — it requests it.**

<!-- TEMPLATE — copy per request
## REQ-NNN — <short title> — filed by frontend-phase-N-bM — <YYYY-MM-DD>
- **Need:** <endpoint / field / filter / shape>
- **Why:** <which screen/flow needs it>
- **Proposed shape:** <JSON sketch, optional>
- **Status:** open | delivered in backend-phase-K
-->

## REQ-001 — Confirm response envelope, wire casing & pagination shape — filed by frontend-phase-0 — 2026-07-02
- **Need:** Authoritative confirmation of three things the frontend types depend on:
  1. **Envelope unwrapping.** The b0 swagger shows every response wrapped in `ApiResult`
     (`{ isSuccess, statusCode, message, requestId, data }`). The frontend's `clientFetch` currently
     returns the **raw body**, so domain `clientApi`s read the payload via `unwrap()` (`data`). Confirm this
     is the intended shape for all endpoints (i.e. payload always under `data`), so the pattern is correct
     before f1+ copy it.
  2. **Wire casing.** Observed swagger properties are **camelCase** (`isSuccess`, `serverTimeUtc`) — not the
     snake_case `api-conventions.md` implies for URL segments. Please confirm JSON body casing is camelCase
     (and, if so, we can note it in the convention doc), or state where it differs.
  3. **Pagination payload.** `api-conventions.md` says lists return `items` + `total` (+ `page`/`page_size`).
     Confirm the exact field names/casing on the wire (we've typed `Paginated<T>` as
     `{ items, total, page, pageSize }` in `client/src/lib/api/types.ts`).
- **Why:** These fix the shared `ApiEnvelope<T>`/`Paginated<T>` types and the `services/{domain}` reference
  pattern every later frontend phase inherits.
- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }`
  and `data: { items: T[], total: number, page: number, pageSize: number }` for lists.
- **Status:** confirmed in refinement-phase-3 — the `ApiResult` envelope (payload under `data`, camelCase body, integer `statusCode`) and `PagedResult` `{ items, total, page, pageSize }` are the intended shapes for all endpoints. **Note:** the list query param binds camelCase **`pageSize`** (case-insensitive); the `page_size` doc occurrences were swept (REQ-010).

## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`.
- **Why:** The A2/B2 OTP screen renders one box per digit and (later) a code-expiry hint. `RequestOtpResult`
  currently exposes only `otpSent` + `resendAvailableInSeconds`, so the frontend hardcodes the box count
  (`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing
  the length makes the box count contract-driven; the expiry lets us show "code expires in …".
- **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }`
- **Status:** delivered in refinement-phase-3

## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code
  vs max-attempts lockout (e.g. `otp_invalid` | `otp_expired` | `otp_locked`), and — for lockout — a
  `retryAfterSeconds` (or `lockedUntil`) field.
- **Why:** The OTP screen has explicit **wrong-code**, **expired-code**, and **max-attempts-lockout** states
  (per the phase spec), but the contract returns the same safe 400 message for all of them, so the frontend
  can't reliably tell them apart. Today it maps a lockout only when it sees the mock's `otp_locked` code and
  otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough
  to avoid account enumeration) would let the UI render the precise state + the unlock countdown.
- **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
- **Status:** delivered in refinement-phase-3

## REQ-005 — Patient `relation` + `conditions` fields — filed by frontend-phase-2-b3 — 2026-07-02
- **Need:** Add two fields to `PatientDto` and the `patients/create` + `patients/update` bodies:
  1. `relation` (`parent`|`spouse`|`child`|`self`, nullable) — the care-recipient's relation to the payer.
  2. `conditions` (string[] of stable codes, e.g. `elderly`/`post_surgery`/`diabetes`/`mobility`/`dementia`).
- **Why:** The A3 onboarding step captures the relation, and the A4 form + E1 patient cards show condition
  chips. Neither field exists on the wire `PatientDto` (only `initialMedicalNotes` free-text). The client
  currently augments them behind the `services/patients` seam (the mock persists them; `USE_PATIENTS_MOCK=true`)
  and drops them on the real path. Adding the columns lets the client flip the flag to the live endpoints.
- **Proposed shape:** `PatientDto { …, relation: string|null, conditions: string[] }`; same fields accepted on
  create/update. Enum for `relation`; `conditions` a stable code list (could also be a normalized child table).
- **Status:** delivered in refinement-phase-3

## REQ-006 — Avatar / object-storage upload route (nurse & customer) — filed by frontend-phase-2-b3 — 2026-07-02
- **Need:** A multipart image-upload endpoint backed by `IObjectStorage` that returns a stored URL, plus an
  `avatarUrl` field on `NurseProfileDto` (and later `CustomerProfileDto`). e.g. `POST api/v1/nurse_profiles/avatar`
  (multipart/form-data) → `{ url }`, and persist `avatar_url` on the profile.
- **Why:** The B7 nurse profile bootstrap and the customer profile both take a profile photo. The b3 contract
  has no avatar field or upload route, and the client fetch layer is JSON-only (can't send multipart). The
  client mocks this behind the `services/profiles` seam (`uploadAvatar` returns an object URL). The real
  `profilesClientApi.uploadAvatar` throws `501` until this lands.
- **Status:** delivered in refinement-phase-3

## REQ-007 — Customer name + preferred-language update — filed by frontend-phase-2-b3 — 2026-07-02
- **Need:** Either add `firstName`/`lastName`/`preferredLanguage` to the `customer_profiles/upsert` body +
  `CustomerProfileDto`, or confirm the customer name is only ever set elsewhere (and how). `MeResult` exposes
  `firstName`/`lastName` read-only with no update endpoint; `CustomerProfileDto` carries only the emergency
  contact.
- **Why:** The customer profile screen edits first/last name + preferred language alongside the emergency
  contact. Absent a wire field/endpoint, the client augments name/language behind the `services/profiles` seam
  (mock-persisted; the real upsert sends only the emergency contact). Confirm the intended home for these so the
  client stops augmenting.
- **Status:** delivered in refinement-phase-3

## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a
  user who holds **both** `customer` and `nurse`, or whether the client should keep owning that choice.
- **Why:** The role router must pick one app for a dual-role user. Absent an `activeRole` in the contract,
  it currently uses the **intended role** carried from the login switch (A1 vs B1), defaulting to the family
  app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
  returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
  flagging in case that changes.
- **Resolution (refinement-phase-2, 2026-07-13):** **The client owns the active-role choice** — `MeResult`
  will *not* gain an `activeRole`. A dual customer+nurse session is disambiguated by the client-carried
  intended role (the A1 vs B1 login switch), defaulting to the family app; `RoleGuard` lets a dual-role user
  move freely between shells. No server change needed. If the backend ever wants to persist a "current role",
  file a new REQ and the router will prefer it. **Confirmed the `/me` `id`-hydration note still holds.**
- **Status:** resolved (client owns it; no backend change)

## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
  `latitude`/`longitude` (decimals) from the request body — the coordinates the user dropped with the map-pin
  picker — and persist those when provided, only falling back to the `IGeocoder` when the client sends none.
- **Why:** The b4 contract's create body geocodes server-side from `addressLine`+city and does **not** accept
  client coordinates, but f3 requires the user to **drop a pin** on the map (a hard client-side validation) so
  the stored coordinate is the user's exact door location for the later EVV distance check (b9) — a geocoded
  street centroid is coarser. The client already sends `latitude`/`longitude` in the create/update body and
  echoes them locally; until the server accepts them, the real path silently ignores them and geocodes instead.
- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store
  them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already
  returns `latitude`/`longitude`.
- **Status:** delivered in refinement-phase-3

## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`).
- **Why:** The address book's **edit** form prefills the cascading province → city → district dropdowns from a
  saved address, and the city list is fetched **per province** (`geo/cities?province_id=`). The DTO carries
  `cityId` but not its province, so the client can't drive the city query to preselect the city without the
  province id. The client currently augments `provinceId` behind the `services/addresses` seam (the mock
  persists it; the real client echoes the just-saved choice), so editing an address that was **loaded fresh from
  the server** can't prefill the province until this lands. `cityId` still implies the province server-side —
  this is purely to prefill the client cascade.
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
- **Status:** delivered in refinement-phase-3

## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05
- **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The
  `catalog.md` route examples write `?page=&page_size=` (snake_case), but the **working** b4 `serviceAreas`
  client binds `pageSize` (camelCase, case-insensitive to the server's `PageSize` property) — the f3 report
  flagged this as the `page_size`→`pageSize` gotcha. The f4 catalog client follows the proven `pageSize` for
  `catalog/categories` and `nurse_variants/list`; the domain filter `category_id` stays snake_case per the doc.
- **Why:** So the real-endpoint swap (f6 flips `USE_CATALOG_MOCK=false`) doesn't silently paginate wrong. If
  the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match;
  if it binds `page_size`, tell us and we'll switch the client (one line per list call).
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
- **Status:** delivered in refinement-phase-3 — server binds **`pageSize`**; the `page_size` occurrences in `dev/contracts/domains/*.md` + `conventions/api-conventions.md` were swept to `pageSize`. (One generated swagger endpoint still shows a `page_size` query name with `x-originalName: pageSize`; it binds `pageSize` case-insensitively.)

## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09
- **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the
  document uploads: `inoNumber` (شماره نظام پرستاری), `specialties` (string[] — stable codes + free-text),
  and optionally `licenseNumber`, `issuingAuthority`, `holderName`, `issuedAt`, `expiresAt`. Proposed:
  `POST api/v1/nurse_verification/credential_details` (or fold into the manual-step `documents` confirm body).
- **Why:** The b6 contract records these structured fields **only on the admin `decide`** — there is no
  nurse-facing endpoint to capture them. B5 needs them at submission time (the INO number is the nurse's
  primary registry key, specialties feed f6 search facets). Today the client collects them and the mock
  persists them; the real `verificationClientApi.submitCredentialDetails` **no-ops** (the document uploads
  it accompanies ARE contract-backed via `upload_url` → PUT → `documents`). Without this, a swap to the real
  backend silently drops the INO number + specialties until an admin re-enters them.
- **Proposed shape:** `POST api/v1/nurse_verification/credential_details` body
  `{ inoNumber, specialties: string[], licenseNumber?, issuingAuthority?, holderName?, issuedAt?, expiresAt? }`
  → `VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields.
- **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every**
  seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
- **Status:** delivered in refinement-phase-3

## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09
- **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose:
  1. **On the `search/nurses` result row** (`NurseSearchResultDto`): the nurse's **display name** and
     **avatar URL** (the C2 card's identity) and a **distance** value (km from the searched area) — b7's
     index row today carries only ids + price/rating/gender/geo ids. Without the name/avatar the card falls
     back to a generic label + initials; distance is simply hidden.
  2. **An aggregated public nurse-profile endpoint** for C3 — proposed `GET api/v1/nurses/{id}/profile` →
     `{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
     totalCompletedBookings, isVerified, inoMembership, attributeChips: string[] (specialty codes),
     services: [{ variantId, displayName, priceIrr (string), priceUnit, sessionCount? }],
     latestReview?: { rating, body, authorMasked, createdAt } }`. Today only the b6 public **trust badge**
     (`nurses/{id}/trust_badge`, giving `isVerified` + `credentialTypes`) and the b5 single-variant read are
     public — there is no name/bio/specialties/**full services list**/latest-review aggregation.
- **Why:** C2/C3 are the trust funnel — the family chooses a real, named, priced nurse here. The
  `services/search` domain is **mock-primary** (`USE_SEARCH_MOCK = true`) precisely because these fields
  aren't available; the real `searchClientApi` maps everything b7/b6 do provide and leaves the above blank.
  When both land, the swap is a single config flip (no hook/component change).
- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add
  `GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
- **Status:** delivered in refinement-phase-3

## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09
- **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's
  **avatar URL**). The b8 DTO carries `variantLabel` + `variantPriceUnit` but no price, so the request
  summary card (C5 + the nurse detail + later f8 booking detail) can't show the priced rate from the DTO.
- **Why:** The C5 awaiting screen and the nurse request detail render the shared `BookingRequestSummaryCard`,
  which prices the service via the f0 money util + i18n unit label. Without a price on the DTO the summary
  hides the amount on the real path. The client augments `variantPrice` behind the `services/bookingRequests`
  seam (the mock supplies it from the chosen variant; the real client leaves it `null`); adding the field
  lets the summary price the service once the domain flips to the real endpoint.
- **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`.
  (Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.)
- **Status:** delivered in refinement-phase-3

## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09
- **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to
  `BookingRequestListItemDto`. Today the nurse-inbox row carries `counterpartyName` (patient) +
  `customerNotes` + gender + times + deadline, but **not** which service was requested.
- **Why:** The f7 nurse inbox card is specced to show the requested **service variant** and the patient's
  age alongside the gender chip + countdown. The list item omits both, so the card shows a notes preview and
  the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing
  `variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
- **Status:** delivered in refinement-phase-3

## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10
- **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate:
  1. **Enum string codes.** The b9 swagger types `status`/`evvStatus`/session `status` as bare `string`
     (no enum constraint). Confirm the stable wire codes match the client unions: `BookingStatus`
     = `pending_payment|confirmed|in_progress|completed|disputed|closed|cancelled`; `BookingSessionStatus`
     = `scheduled|in_progress|completed|missed|cancelled`; `VisitVerificationStatus`
     = `pending|checked_in|completed`. (They match the contract doc's "Enums used" — this just asks that the
     serialized JSON emits these exact snake_case codes, not PascalCase/int.)
  2. **`checkInAddressMatch` tri-state semantics.** The EVV banner keys off it as: `true` = in range
     («موقعیت تایید شد»), `false` = out-of-range/advisory-under-review («موقعیت خارج از محدوده»), `null` =
     GPS unavailable/denied («موقعیت ثبت نشد»). Confirm the server returns `null` (not `false`) when the
     nurse checked in **without** coordinates (GPS denied), so the UI can distinguish "flagged mismatch"
     from "no position captured". A mismatch stays advisory server-side (support alert, never a block) — the
     UI mirrors that.
- **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes;
  a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now),
  but worth locking before f9/f13 consume the same shapes.
- **Status:** confirmed in refinement-phase-3 — booking/session/EVV statuses serialize as the exact snake_case string codes the client unions expect (verified in code); `checkInAddressMatch` is `bool?` = `null` when GPS was absent **or** the frozen address has no resolvable coordinate, `false` = advisory out-of-range (never a block), `true` = in range.

## REQ-016 — Checkout summary for C6 (served gross/commission/VAT breakdown) — filed by frontend-phase-9-b10 — 2026-07-10
- **Need:** A customer-facing read that serves the C6 «خلاصه و پرداخت» money rows for an
  `accepted_awaiting_payment` request: the three b10 amounts (`grossPriceIrr`,
  `balinyaarCommissionIrr`, `nursePayoutAmount`) **plus the display decomposition** — service cost,
  commission **net of VAT**, `vatIrr` + `vatRate` — and the nurse/variant/schedule mini-info +
  `paymentDeadlineAt`.
- **Why:** The b8 `BookingRequestDto` is money-free by design and no checkout-summary endpoint exists,
  but C6 must show a breakdown that **reconciles to the rial** (service + commission + VAT = total) and
  the client is forbidden from deriving commission or tax itself (no float math, rates are server
  config). Today the whole C6 money surface is mocked (`services/payment` mock computes the split from
  the mock variant price at the configured 12% fee / 10% VAT).
- **Proposed shape:** `GET api/v1/booking_requests/checkout_summary/{id}` (owner-scoped) →
  `{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit,
  sessionCount, requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt,
  serviceCostIrr, commissionIrr, vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr,
  nursePayoutAmount }` — the client's real `paymentClientApi.getCheckoutSummary` already targets this
  slug and unwraps this exact shape (`client/src/services/payment/types.ts: CheckoutSummaryDto`).
- **Status:** delivered in refinement-phase-3

## REQ-017 — Client-readable payment outcome + `bookingId` on a converted request — filed by frontend-phase-9-b10 — 2026-07-10
- **Need:** After the gateway redirect returns, the client needs to learn (a) the payment transaction's
  status (`pending|succeeded|failed`) and (b) **which booking** the capture created. Either a
  transaction read (e.g. `GET api/v1/payment_transactions/{id}` or
  `GET api/v1/bookings/{bookingRequestId}/payments/latest`, owner-scoped) or, minimally, a
  `bookingId` field on `BookingRequestDto` once `status = converted`.
- **Why:** b10 confirms captures inside the PSP webhook (correct — the client is never trusted), so the
  frontend's pending-callback state can only poll `booking_requests/get/{id}` and map
  `converted → succeeded` / `payment_deadline_expired → failed`. That works, but it cannot distinguish a
  *declined* payment (still `accepted_awaiting_payment`, retry allowed) from a *slow* callback, and the
  confirmation screen cannot deep-link «مشاهده رزرو» or «دانلود فاکتور» because the converted request
  never reveals its booking id. The client DTO already carries a client-augmented
  `bookingId: number | null` (mock fills it; real path returns null and the UI falls back to the
  bookings list, hiding the invoice link).
- **Proposed shape:** add `bookingId: long?` to `BookingRequestDto` (null until converted) **and/or**
  `GET api/v1/bookings/{bookingRequestId}/payments/latest` → `{ transactionId, status,
  gatewayReferenceCode, bookingId? }`.
- **Status:** delivered in refinement-phase-3

## REQ-018 — Customer invoice availability after capture (auto-issue or owner-issue) — filed by frontend-phase-9-b10 — 2026-07-10
- **Need:** Make the b11 invoice reachable by the paying customer right after capture: auto-issue the
  commission invoice on card capture (idempotent per booking, as `POST admin_invoices` already is), or
  allow the owning customer to trigger the idempotent issue on first `GET api/v1/invoices/{bookingId}`.
- **Why:** The f9 confirmation screen offers «دانلود فاکتور», but b11 issues invoices only via the
  admin-only `POST api/v1/admin_invoices`, so a customer's `GET invoices/{bookingId}` 404s until an
  admin acts. The UI handles the 404 as a "فاکتور هنوز صادر نشده است" state (and the mock auto-issues at
  capture to demo the full flow), but on the real rails every fresh payment would land on that empty
  state.
- **Status:** delivered in refinement-phase-3

## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g.
  `POST api/v1/bookings/{bookingId}/cancel` (owner-scoped) with body
  `{ sessionIds?: long[], reasonCategory: string, reasonNotes?: string }` →
  the created refund summary (`{ id, bookingId, status, refundChannel, amount, expectedCustomerRefundEta,
  reference }`, i.e. the `refunds/{id}/status` shape). `sessionIds` omitted = cancel all un-started
  (remaining) sessions; completed-and-verified sessions stay payout-eligible.
- **Why:** b11 shipped refunds **admin-only** (`POST admin_refunds`) — there is **no** customer path to
  request a cancellation, but f10's whole cancel flow is customer-initiated (the customer *requests*; an
  admin still *approves/processes* the money). The client mocks this behind the `services/refunds` seam:
  the mock flips the booking to `cancelled` (stamping the b9 cancellation snapshot), decomposes the refund
  across the two fee legs, and returns a card-immediate (`succeeded`) or BNPL-`processing` refund. The real
  `refundsClientApi.cancelBooking` already targets this slug.
- **Proposed shape:** `POST api/v1/bookings/{bookingId}/cancel` body as above → `RefundStatusDto`. The
  server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the
  balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the
  customer surface just needs to *create* the cancellation request and read the resulting refund.
- **Status:** delivered in refinement-phase-3

## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the
  customer confirms, incl. the per-session refundability breakdown. Proposed
  `GET api/v1/bookings/{bookingId}/cancellation_policy` (owner-scoped) →
  `{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage,
  refundAmountIrr, feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr,
  appliesTo, leadTimeLabel, refundChannel, expectedCustomerRefundEta,
  sessions: [{ bookingSessionId, sessionIndex, scheduledDate, refundable, reasonCode }] }` (IRR fields are
  digit-strings; `refundAmountIrr + feeAmountIrr = refundableAmountIrr`; the fee-leg split is served,
  never client-derived).
- **Why:** The phase's load-bearing rule is **disclose the fee/refund % before confirm** — an
  outside-policy fee is never a surprise. b9 snapshots `cancellationPolicyCode`/`cancellationRefundPercentage`/
  `refundableAmountIrr` on the booking only *after* a cancel; there is no pre-cancel preview that resolves
  the tier by current lead time and enumerates which sessions are refundable (un-started) vs locked
  (completed-and-verified). The client mocks the whole preview behind the `services/refunds` seam; the tier
  **codes** (`free_24h` / `partial_under_24h` / `customer_no_show`) are client-invented placeholders (the
  product doc pins no wire codes) mapped to i18n keys — please define the canonical `cancellation_policy_code`
  set so the client maps the real codes.
- **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set
  (`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys.
- **Status:** delivered in refinement-phase-3

## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** Two additions to the customer refund surface:
  1. **Reach a refund from its booking.** `GET api/v1/refunds/by_booking/{bookingId}` (owner-scoped) →
     the `refunds/{id}/status` shape, or `404` when the booking has no refund. Today the only customer read
     is `GET refunds/{id}/status` keyed by a **refund id** the customer can't obtain (the refund id lives on
     the admin-only `GET admin_refunds` worklist).
  2. **Expose the decomposition to the customer.** Add `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`,
     `refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt` to the customer
     `refunds/{id}/status` payload (they exist on the admin-only `RefundListItem`).
- **Why:** f10's refund-status screen deep-links from a booking and renders (where the design calls for it)
  a **fee-leg transparency split** (Balinyaar-fee-refunded vs service-cost-refunded). Without (1) the
  customer can't find their refund; without (2) the split can't render on the real path (the client mock
  fills all six fields; the real `refundsClientApi` leaves them `null` and the split is hidden).
- **Also (minor):** please confirm `provider_commission_reversed_amount` (the BNPL provider's own
  commission on a revert) is **nullable** on the refund shape and reconciled from the provider response —
  the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure
  as opaque/never customer-facing.
- **Status:** delivered in refinement-phase-3

## REQ-022 — BNPL provider/plan options + repayment schedule (D1/D2/D4) — filed by frontend-phase-11-b12 — 2026-07-10
- **Need:** Two customer-facing reads the installment checkout renders that b12 serves **nothing** for:
  1. **Provider + plan options** — proposed `GET api/v1/checkout_bnpl/options/{bookingRequestId}` (owner-scoped) →
     `{ bookingRequestId, requestStatus, orderAmountIrr, bnplEligible, providers: [{ providerCode, tagline?,
     plans: [{ planId, termMonths?, installmentCount, feePercent, downPaymentPercent, monthlyAmountIrr,
     downPaymentIrr, totalIrr }] }] }` — the payable amount plus the provider set (دیجی‌پی/اسنپ‌پی/اقساط
     بالین‌یار) and, **per plan, the served monthly amount / down-payment / total** (all IRR digit-strings).
  2. **Repayment schedule** — proposed `GET api/v1/checkout_bnpl/schedule/{bookingRequestId}?provider=&plan=`
     → `{ bookingRequestId, providerCode, planId, totalIrr, downPaymentIrr, installmentCount,
     rows: [{ sequence, kind: 'down_payment'|'installment', dueDate, amountIrr }] }` — the D4 پیش‌پرداخت +
     قسط ۱…N table.
- **Why:** b12 is order-centric (`eligibility` / `initiate` / status / webhook) and the contract **explicitly
  does not model the customer's repayment schedule** (`installment_count` is informational). But D1–D4 must
  show a provider list, per-plan monthly/down-payment figures, and a due-dated repayment table — and the
  client is forbidden from computing any money figure. Today the whole D1/D2/D4 money surface is mocked
  (`services/bnpl` mock computes the plan split + schedule with BigInt at the configured provider fees).
  When these land, the swap is a single `USE_BNPL_MOCK=false` flip.
- **Note on provider set:** the wireframe includes an **in-house `balinyaar`** plan not in the b12
  `provider_code` enum (`snapppay|digipay|tara|torobpay`). Please add `balinyaar` (or state how the in-house
  plan is modelled) so `providerCode` stays a closed set.
- **Status:** partially delivered in refinement-phase-3 — `balinyaar` added to the `provider_code` enum. **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` (per-plan monthly/down-payment split + due-dated repayment table) — b12 deliberately does not model the customer repayment schedule and there is no installment ledger to serve it from; keep D1/D2/D4 mocked until a provider-schedule integration or schedule table lands.

## REQ-023 — BNPL eligibility should accept the D3 credit-check inputs (national ID / mobile / consent) — filed by frontend-phase-11-b12 — 2026-07-10
- **Need:** Either extend `POST api/v1/checkout_bnpl/eligibility` to accept `{ nationalId, mobile, consent }`
  alongside `{ bookingRequestId, providerCode }`, or define a separate provider-KYC step that captures them.
- **Why:** D3 (اعتبارسنجی) collects کد ملی + موبایل + a consent checkbox and submits them for the provider
  credit inquiry, but the b12 eligibility body is only `{ bookingRequestId, providerCode }`. The client sends
  the extra fields today (ignored server-side until the KYC step exists) and the mock uses them for the
  deterministic declined-path demo. The response already carries `eligibilityStatus` + `creditCeilingIrr`,
  which D3 renders — only the request inputs are the gap.
- **Status:** delivered in refinement-phase-3 — `checkout_bnpl/eligibility` now accepts `{ nationalId, mobile, consent }` (consent required when the KYC inputs are present; supplied mobile drives the inquiry, else the account mobile). Mock still uses only the mobile until the real KYC step exists.

## REQ-024 — BNPL provider-reported installment status for the Wallet (D5) + customer bookingId link — filed by frontend-phase-11-b12 — 2026-07-10
- **Need:** Two additions for the Wallet installment view and the confirmation deep-link:
  1. **Provider-reported installment status** — proposed `GET api/v1/checkout_bnpl/wallet_installments`
     (owner-scoped) → `[{ bnplTransactionId, bookingId?, providerCode, status, serviceLabel,
     outstandingBalanceIrr, installmentCount, nextDueDate?, nextAmountIrr?, earlyPayUrl?,
     installments: [{ sequence, kind, dueDate, amountIrr, status: 'paid'|'due_soon'|'upcoming'|'overdue' }] }]`
     — the مانده بدهی + per-installment due list D5 renders. This is **provider-reported status Balinyaar
     proxies, not a Balinyaar-managed ledger** (the repayment schedule is out of the b12 contract's scope);
     `earlyPayUrl` is a hand-off link to the provider (early-pay is a provider action, never a Balinyaar
     transaction).
  2. **Customer-readable `bookingId`** on the settled BNPL order (`BnplOrderStatus` sets it at settle) so the
     return surface can deep-link the reused f9 confirmation → booking detail + invoice (same gap as REQ-017
     on the card path).
  3. **A customer by-request order lookup** — proposed `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}`
     → `BnplOrderStatus`. The b12 `GET checkout_bnpl/{id}` is keyed by the **order id**, but the return-poll
     surface holds the **request id** (from the C6 handoff), not the order id. (The settle-on-return path
     reads the order **by its own id** via the `transaction_id` carried through the handoff, which is already
     contract-correct — only the standalone poll needs a by-request read.)
- **Why:** D5 (پیگیری اقساط) is the load-bearing "provider owns the schedule, Balinyaar only displays it"
  surface, and b12 models none of the per-installment schedule/status. The client mocks the whole D5 read
  behind the `services/bnpl` seam (seeded plan + a plan pushed on each settled checkout). When it lands the
  swap is one config flip.
- **Status:** partially delivered in refinement-phase-3 — (2) `bookingId` on the settled order **already present** on `BnplOrderStatusDto` (confirmed); (3) `GET checkout_bnpl/by_request/{bookingRequestId}` added (owner-scoped). **DEFERRED:** (1) `checkout_bnpl/wallet_installments` — per-installment provider-reported status Balinyaar does not own/track (no installment ledger in b12); needs provider integration. Keep D5 mocked.

## REQ-025 — Nurse-read earnings surface: four-bucket balance + per-booking earnings list + nurse payout detail — filed by frontend-phase-12-b13 — 2026-07-10
- **Need:** b13 serves the nurse exactly one endpoint (`GET api/v1/nurse_payouts/history` → `NursePayoutHistoryDto`).
  The f12 earnings screen needs three more **nurse-scoped, read-only** reads (all tenancy-scoped to the caller):
  1. **Earnings balance (four buckets + signed net)** — proposed `GET api/v1/nurse_payouts/earnings_balance` →
     `{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr, clawbackOutstandingIrr, netPayableBalanceIrr }`.
     `netPayableBalanceIrr` is the **ledger-derived, SIGNED** payable balance (may be **negative** = "owed back";
     never clamp). `paidTotalIrr` is a lifetime total and does **not** enter the net balance.
  2. **Per-booking earnings list + money-state** — proposed
     `GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=` → `PagedResult<NurseEarningsItem>` where
     `NurseEarningsItem = { bookingId, patientName, scheduledDate, grossPriceIrr, balinyaarCommissionIrr,
     nursePayoutAmount, state: 'pending'|'eligible'|'paid'|'clawback_applied', disputeWindowEndsAt?,
     payoutEligibleAt?, paidAt?, transferReference?, nursePayoutId?, batchId?, clawbackAppliedIrr?, netAmountIrr? }`.
     `state` is derived **server-side** from `bookings.status` + `dispute_window_ends_at < now` + the payout link +
     any clawback (the client must never compute eligibility). Filterable by `state`.
  3. **Nurse-readable payout detail (batch context + booking links)** — proposed
     `GET api/v1/nurse_payouts/{id}` → a nurse-scoped analogue of the admin `PayoutBatchDetailDto`/`PayoutDto`:
     `{ id, batchId, status, grossEarningsIrr, clawbackAppliedIrr, netAmountIrr, amountIrr, maskedIban,
     transferReference?, paidAt?, failureReason?, batch: { id, periodStart, periodEnd, processingDate, status,
     totalAmount, payoutCount, processedAt? }, bookings: PayoutBookingLinkDto[] }`. The admin `batches/{id}` detail
     is admin-only; a nurse needs to reach **their own** payout's batch window + covered bookings for reconciliation.
  4. **`failureReason` on the nurse history DTO** — `NursePayoutHistoryDto` has no `failureReason` (it's on the
     admin `PayoutDto`). A `failed` payout in the nurse history should carry its reason so the read-only failure
     banner can show it (the nurse cannot retry — retry stays an admin action).
- **Why:** the f12 nurse earnings screen (`services/payouts`) renders the net payable balance + four-bucket
  breakdown, the state-segmented earnings list (pending/eligible/paid/clawback_applied), and the payout/batch
  reconciliation detail. Only `getNursePayoutHistory` maps a live route; the other three are mocked behind the
  `PayoutsApi` seam (real-shaped fixtures covering all four states + a negative net balance + a failed payout).
  `payoutsClientApi` already targets the proposed slugs — when these land the swap is a single
  `USE_PAYOUTS_MOCK=false` flip; no hook/component change.
- **Note (money invariants the server owns):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
  `net_amount = gross_earnings − clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its
  `gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted).
- **Status:** delivered in refinement-phase-3

## REQ-026 — Review eligibility + my-review-for-booking reads (+ masked author confirmation) — filed by frontend-phase-13-b14 — 2026-07-10
- **Need:** Three customer-facing additions the leave-a-review flow renders that b14 does not serve:
  1. **Review eligibility** — proposed `GET api/v1/bookings/{bookingId}/review_eligibility` (owner-scoped) →
     `{ canReview: boolean, reason?: 'not_completed'|'already_reviewed'|'not_owner'|'not_found' }`. Lets the
     CTA/form gate on "this booking is completed/closed AND not already reviewed" without probing the 1:1 `409`.
  2. **My review for a booking** — proposed `GET api/v1/bookings/{bookingId}/my_review` (owner-scoped) →
     `{ moderationStatus: 'pending_moderation'|'published'|'hidden'|'rejected'|'none', rating?, body?, tagCodes[], createdAt? }`.
     Lets the CTA show the **persistent "under review" state across sessions** (a returning customer must never
     see a second form) — today only the just-submitted result is known client-side.
  3. **Masked author confirmation.** `ReviewListItemDto` (the public list) carries **no customer name**. The
     client renders a generic masked author («کاربر بالین‌یار») on the profile reviews tab. Please confirm this
     omission is intentional (privacy), or add a **server-masked** display name field if a masked name is wanted.
- **Why:** b14 serves the submit (`POST bookings/{id}/review`), the public `GET nurses/{id}/reviews`, and the tag
  rollup — but there is no eligibility read and no my-review read, and moderation (`pending_moderation → published`)
  is admin-only (f15). So the `services/reviews` domain is **mock-primary** (`USE_REVIEWS_MOCK = true`): the mock
  reads the shared f8 bookings store for completed-booking eligibility, tracks the submission for the under-review
  state, and seeds a published list per nurse. The real `reviewsClientApi` maps `getNurseReviews`/`createReview`
  1:1 and targets the two proposed slugs for the gaps — one config flip when they land.
- **Status:** delivered in refinement-phase-3

## REQ-027 — Family-owned care record (medications/routine/tasks) + record access + structured task results — filed by frontend-phase-13-b14 — 2026-07-10
- **Need:** The b14 `care_records` GET/POST serve the **nurse-authored visit-note history** (سوابق) — that half
  is **real and consumed**. But the E2 record viewer's **family-owned editable record** and the nurse **task
  checklist** have **no backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md`
  models them). Proposed:
  1. **Family-owned record** — `GET/PUT api/v1/patients/{patientId}/care_record` (owner-scoped for read;
     owning-customer for write) → `{ patientId, medications: [{ id, name, dosage?, frequency, timingNote? }],
     routine: [{ id, label, timeOfDay?, note? }], tasks: [{ id, label, done }] }`. **Patient-scoped, not
     booking-scoped**; the **customer** owns/edits it; it persists across nurse changes. (New entity/table.)
  2. **Record access** — proposed `GET api/v1/patients/{patientId}/record_access` →
     `{ canView, canEdit, canAppendNote, deniedReason? }` **or** confirm the client should derive access purely
     from the **`403` on the history read** (the two-stage clinical-disclosure rule). Today the mock returns a
     `403` for a foreign patient id so the non-leaking access-denied card is demoable.
  3. **Structured task results on a visit note** — `WriteCareRecordBody` is only `{ bookingId?, body }`. The
     nurse's ticked checklist is currently **folded into `body`** as a leading summary line. A structured
     `taskResults: [{ label, done }]` field would preserve it (and let the history render the checklist as chips).
- **Why:** E2 (داروها/روتین/سوابق/وظایف) + the nurse E3 task-checklist are the continuity-of-care surface. The
  visit-note history/append are real (b14) and mapped 1:1 in `patientRecordsClientApi`; the family-record + access
  methods target the proposed slugs (REQ-027) and the domain is **mock-primary** (`USE_PATIENT_RECORDS_MOCK = true`)
  until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether
  the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking).
- **Status:** delivered in refinement-phase-3

## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10
- **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message
  routes don't yet expose (the two ticket screens work today because `services/tickets` is **mock-primary** —
  `USE_TICKETS_MOCK = true`; the real `ticketsClientApi` maps every live b15 route 1:1 and leaves these blank):
  1. **`unreadCount` + `lastMessageAt` on `TicketSummaryDto`.** The inbox card shows an **unread indicator** and
     sorts/labels by **last activity**, but the wire summary carries only `createdAt` (no unread, no last-message
     time). Today the client falls back to `createdAt` and hides the unread badge on the real path; the mock
     supplies both. Proposed: `TicketSummaryDto { …, lastMessageAt: string (UTC), unreadCount: int }` (unread =
     messages after the caller's `last_read_at`, internal excluded).
  2. **A message author display label on `TicketMessageDto`** (or on `TicketParticipantDto`). The thread bubble
     labels each message by author, but the DTO carries only `senderId` — no name. The client derives the label
     from the participant **role** (`author_customer`/`author_nurse`/`author_support`/`author_system`) and never
     shows a raw identity (privacy — the thread is deliberately not a contact directory). Please **confirm this
     role-label approach is intended**, or add a **server-masked** display name if a name is wanted. (No phone /
     no real name is the safe default.)
  3. **A user-facing by-booking ticket lookup/filter.** `GET /tickets` (the user list) filters only by
     `Status`/`ReferenceCode` — there's no `bookingId` filter (only the **admin** list has one). The
     "Get support" action on a booking is specced to **jump to the existing coordination ticket** if one exists;
     without a by-booking read the client can't find it. The mock approximates this via idempotent
     `openTicket(category:'coordination', bookingId)` (returns the existing thread). Proposed: add a `bookingId`
     query param to `GET /tickets`, or `GET /tickets/by_booking/{bookingId}?category=coordination`.
  4. **An idempotency / `clientMessageId` field on `POST /tickets/{id}/messages`.** The optimistic send generates a
     `clientMessageId` to reconcile the pending bubble; the server has no field for it, so a retried send (network
     flap) could create a duplicate server-side. The client does **not** send it today (reconciliation is
     client-only). Proposed: accept an optional `clientMessageId`/`idempotencyKey` on the post-message body and
     dedupe on it, echoing it back on `PostMessageResult`.
- **Why:** the f14 inbox (unread + last-activity), the thread (author labels + jump-to-coordination), and the
  optimistic composer render these; the domain is mock-primary precisely because (1)/(3) aren't served and the
  linked bookings are themselves mock-primary. When they land the swap is a single `USE_TICKETS_MOCK = false` flip
  (no hook/component change) — `ticketsClientApi` already maps the live routes.
- **Status:** delivered in refinement-phase-3 — (1) `unreadCount` + `lastMessageAt` on `TicketSummaryDto` (unread = non-internal messages from others after the caller's `last_read_at`, stamped when the participant fetches the thread; admin queue = 0). (3) `bookingId` query param on `GET /tickets`. (4) optional `clientMessageId` on `POST /tickets/{id}/messages` (deduped, echoed on `PostMessageResult`). (2) **Confirmed:** the role-label approach is intended — no raw identity/name is added (privacy).

## REQ-029 — Config `updatedAt`/`updatedBy` on `PlatformConfigDto` — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the f15 config editor shows each row's last-changed meta ("updated {date} by {actor}"), but the b1
  `PlatformConfigDto` carries only `{ key, value, dataType, description }`. Proposed: add `updatedAt` (UTC) +
  `updatedBy` (actor id or display) to the DTO (the audit trail already records the change; this surfaces the
  latest on the row without opening the drawer). Mock supplies both; the real row degrades gracefully without.
- **Why:** finance needs the effective value + who last touched it at a glance. `services/admin` is mock-primary
  (`USE_ADMIN_MOCK = true`); `adminClientApi.listConfigs` maps the live route 1:1 and leaves these undefined.
- **Status:** delivered in refinement-phase-3 — `updatedAt` + `updatedBy` on `PlatformConfigDto` (from the entity's audit fields; falls back to creation).

## REQ-030 — Audit-trail filters: actor / action / date-range — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** `GET audit/get_audit_trail` filters only by `entity_type` + `entity_id`. The f15 audit viewer offers
  actor, action, and from/to date filters. Proposed: add `actor_id`, `action`, `from`, `to` query params (the mock
  honours all four). Until then the real client passes only the supported two and the rest degrade.
- **Why:** ops audits by actor and by time window, not only by a single entity. Mock-primary.
- **Status:** delivered in refinement-phase-3 — `GET audit/get_audit_trail` now also filters by `actorId`, `action`, `from`, `to` (all optional; `entityType`/`entityId` are now optional too). **Note:** query params bind camelCase (`actorId`/`from`/`to`), like `pageSize` — not `actor_id`.

## REQ-031 — RBAC role grant/revoke/list endpoints — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the b15 contract exposes no role-management endpoints. The (optional, **DEFERRED-IF-MISSING**) admin
  roles console needs: `GET admin_roles/list_roles[?user_id=]` returning `RoleGrant[]` (`{ userId, role, grantedBy,
  grantedAt, revokedAt }`), `POST admin_roles/grant_role { userId, role }`, `POST admin_roles/revoke_role
  { userId, role }`, where `role` is one of `super_admin|admin|support|finance|moderation`. The screen is built
  against the mock and flagged DEFERRED-IF-MISSING; swap `USE_ADMIN_MOCK=false` once the routes land.
- **Why:** to manage which users hold which admin scopes. Not on the testable acceptance path.
- **Status:** deferred in refinement-phase-3 — the RBAC `admin_roles/list|grant|revoke` console. The admin sub-role vocabulary + phone-OTP admins are seeded (refinement-phase-2), but a full grant/revoke management surface is admin-console tooling not on the frontend acceptance path (flagged DEFERRED-IF-MISSING); keep `USE_ADMIN_MOCK` for `/admin/roles`. To deliver: 3 endpoints over `user_roles` (grant/revoke audited) + a `RoleGrant[]` read.

## REQ-032 — Partner-portal split reads + activate/suspend + IBAN write-then-masked — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the b15 contract has admin partner-center CRUD/verify/sponsor + a single `GET /centers/{id}/dashboard`
  portal endpoint. The f15 partner portal + admin management need: (1) a **center-scoped "my center"** resolution
  (`GET centers/me` or `/centers/me/dashboard`) so a center admin never passes a raw id; (2) split portal reads
  `GET centers/me/nurses`, `GET centers/me/bookings?status=&page=`, `GET centers/me/settlement?page=` (or these as
  fields on the dashboard); (3) an **activate/suspend** toggle (`POST admin/partner-centers/{id}/set-active
  { isActive }`) — verify only activates; (4) confirm the **write-then-masked** IBAN contract (PATCH accepts a
  full `settlementIban`, GET returns only `settlementIbanMasked` last-4). Also the admin **roster read**
  (`GET admin/partner-centers/{id}/nurses`). `services/partnerCenter` is mock-primary (`USE_PARTNER_MOCK`).
- **Why:** the portal (separate authz scope, own-center tenancy) + the admin management screens render these.
- **Status:** partially delivered in refinement-phase-3 — (3) activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }` added; (4) **confirmed** the write-then-masked IBAN contract (PATCH accepts full `settlementIban`; reads return only masked last-4). **Route casing confirmed:** the b15 admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an intentional b15 divergence, so the frontend's kebab-case guess is CORRECT (no change needed). **DEFERRED:** (1) `centers/me` + (2) the split portal reads (`centers/me/nurses|bookings|settlement`) — these need the user↔partner-center admin association that REQ-038 deferred; `/partner` stays reachable by direct nav + the partnerCenter mock until that seed + `/me` signal land.

## REQ-033 — Partner settlement: per-booking commission invoices + invoice `total` — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the merchant-of-record settlement view lists per-booking **commission invoices** (b11 `Invoice`
  shape) filtered to the center, but there's no center-scoped invoice-list route (see REQ-032). Also the b11
  `Invoice` DTO has no `total` — the settlement row shows `platform commission + BNPL commission + VAT = total`;
  the client currently **sums the served legs** for the total (a display sum, not a load-bearing re-derivation).
  Proposed: serve `totalIrr` on the invoice (= commission + bnplCommission + vat), plus a center-scoped list.
- **Why:** the settlement/invoice view (rendered only when `is_merchant_of_record`) needs a reconciling total.
  VAT stays on the commission line only; the rate is config-driven (`vat_rate`), never hardcoded.
- **Status:** partially delivered in refinement-phase-3 — `totalIrr` (= platform commission + BNPL commission + VAT) added to `InvoiceDto`. **DEFERRED:** the center-scoped invoice list (depends on the partner portal split reads, REQ-032).

## REQ-034 — Verification admin: nurse-level queue + on-demand document URL + whole-verification approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** three gaps in the b6 admin surface for the f15 review queue: (1) `GET admin_verifications` returns
  **one row per pending step**; the queue UI is **nurse-level** (progress "X of Y", next step, expiring flag) — a
  nurse-grouped list (or a `group_by=nurse` param) would avoid a lossy client fold. (2) A **per-document on-demand
  signed-URL** route (`GET admin_verifications/documents/{documentId}/url`) — signed URLs are short-lived and must
  be fetched on demand / re-requested on expiry, not read from the long-lived case payload. (3) **whole-
  verification** approve/reject (`POST admin_verifications/{id}/approve` and `/reject`) — today the aggregate flips
  implicitly when the last step is decided; the UI wants an explicit Approve (enabled only when all steps pass) /
  Reject action. `services/verification` is mock-primary.
- **Why:** the queue + per-nurse case + signed-URL document viewer render these. The client never writes
  `is_verified` — the server flips it transactionally (§5).
- **Status:** deferred in refinement-phase-3 — verification admin polish (nurse-grouped queue, on-demand signed document URL, explicit whole-verification approve/reject). The per-step admin surface + the transactional `is_verified` flip already exist (b6); these are ergonomic refinements to the admin queue, not on the frontend acceptance path.

## REQ-035 — Refund preview + explicit approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** b11 `POST admin_refunds` **creates and executes** in one call, so there is no way to render a
  **preview** (tiered percentage + fee/payout decomposition + channel + BNPL ETA + will-create-clawback) before
  initiating. Proposed: `GET admin_refunds/preview?booking_id=&ticket_id=` returning the server-computed
  decomposition (the client renders it, never recomputes the split/percentage). Also explicit
  `POST admin_refunds/{id}/approve` and `/reject` for the provider-revert-failure → retry and the decline paths
  (today the single POST both creates + executes). `services/refunds` admin methods are mock-primary;
  `initiateRefund` maps the live `POST admin_refunds`.
- **Why:** the ticket-linked refund panel shows the preview, then initiate → (retry on provider failure) / reject.
- **Status:** deferred in refinement-phase-3 — refund admin preview + explicit approve/reject (the single `POST admin_refunds` creates+executes today). The customer preview (REQ-020) IS delivered and serves the same decomposition; the admin-side preview/approve/reject split is admin-console tooling.

## REQ-036 — Payout single preview endpoint + `holidayShifted` flag + record-transfer-reference — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the f15 payout dashboard wants (1) a **single preview** call returning eligible + skipped + the
  holiday-shifted processing date together (b13 `GET admin_payouts/eligible` is paged and returns only the eligible
  page — skipped nurses come back on the batch `POST`); a `GET admin_payouts/preview?periodStart=&periodEnd=`
  returning `{ eligible[], skipped[], processingDate, holidayShifted, totalNet }` would drive the dry-run cleanly.
  (2) A `holidayShifted` boolean on `PayoutBatchDto` / the preview so the UI can badge a batch whose processing
  date moved off a bank-closed day (currently not on the DTO). (3) A **record-transfer-reference** route
  (`POST admin_payouts/{payoutId}/transfer_reference { reference }`) for finance to attach the real bank reference
  (b13 has `mark_failed` but no reconcile-reference write). `services/payouts` admin methods are mock-primary; the
  run/retry map the live routes with the `Idempotency-Key` header.
- **Why:** preview → run (idempotency-keyed, no double-pay) → detail with per-nurse retry + transfer-ref reconcile.
- **Status:** deferred in refinement-phase-3 — payout admin single-preview endpoint + `holidayShifted` flag + record-transfer-reference route. The eligible/skipped data is already returned by the batch generate/`GET admin_payouts/eligible`; a consolidated dry-run preview + reconcile-reference write are admin-console refinements.

## REQ-037 — Moderation queue `tagCodes` on `ModerationQueueItemDto` — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the f15 moderation queue renders each review's tag chips, but `ModerationQueueItemDto` (b14) doesn't
  list `tagCodes` (only the public `ReviewListItemDto` does). Proposed: add `tagCodes: string[]` to the moderation
  DTO. The client defaults to `[]` meanwhile. `services/reviews` moderation methods are mock-primary; `moderateReview`
  maps the live `PATCH reviews/{id}/status` and the queue maps `GET admin/reviews/moderation_queue`.
- **Why:** moderators see the tags a review carries before publishing/hiding.
- **Status:** delivered in refinement-phase-3

## REQ-038 — Signal on `/me` that the caller administers a partner center (partner auto-routing) — filed by refinement-phase-2 — 2026-07-13
- **Need:** add a boolean/id on `MeResult` — e.g. `administersPartnerCenterId: number | null` (or a plain
  `isPartnerCenterAdmin: bool`) — indicating the signed-in user is the admin of a `partner_center`.
- **Why:** the partner portal (`/partner`) is a **separate authz scope** — a center admin is not a Balinyaar
  admin, and partner-ness is **not** derivable from `me.roles`. So `resolveRoleDestination` (the login role
  router) can't route a partner admin to `/partner` on login the way it routes customer/nurse/admin. Today
  `/partner` is only reachable by **direct navigation** (in dev the `services/partnerCenter` mock resolves a
  center for `useMyPartnerCenter`, so the shell renders instead of access-denied); there is no seeded real
  partner-center↔user association and no `/me`-level signal, so login→`/partner` can't be delivered. With this
  field the router gains a partner branch and the demo seed can associate a phone user with a seeded center.
- **Proposed shape:** `MeResult.administersPartnerCenterId?: number | null`; when non-null,
  `resolveRoleDestination` routes to `ROUTES.PARTNER`. Pair with a Development seed (a partner-center + a
  `demo_partner_*` phone user linked as its admin) so the actor is reachable end-to-end like the others.
- **Status:** open (partner login-routing deferred; `/partner` reachable by direct nav + the partnerCenter mock)
- **Status:** open

## REQ-039 — WebOTP-conformant OTP SMS template — filed by ui-phase-3 — 2026-07-17
- **Need:** the OTP SMS body must end with the origin-bound last line `@<domain> #<code>` (the
  [WebOTP](https://web.dev/articles/web-otp) / origin-bound one-time-code convention), e.g.:
  ```
  کد ورود بالین‌یار: 123456
  @balinyaar.com #123456
  ```
  This is a server/SMS-template change on the Kavenegar adapter (refinement-phase-8) — zero API-shape impact,
  just the text body the SMS gateway sends.
- **Why:** the client now feature-detects `'OTPCredential' in window` and calls
  `navigator.credentials.get({ otp: { transport: ['sms'] } })` (`components/auth/useWebOtp.ts`, wired into
  `OtpStep`) plus `autoComplete="one-time-code"` on every OTP box (`OtpInput.tsx`) — but Chrome's WebOTP API
  and the iOS/Android keyboard "from SMS" suggestion only auto-read a code when the SMS ends with this exact
  origin-bound line. Without it, the client work still ships (autoComplete alone helps on iOS/some Android
  keyboards) but silently degrades to manual entry everywhere.
- **Proposed shape:** append `\n@<domain> #<code>` as the SMS's last line, where `<domain>` is the site's own
  host (no scheme, no trailing slash) and `<code>` is the exact code the user must enter.
- **Status:** open — client-side WebOTP wiring ships regardless and degrades gracefully until this lands.
