Files
baya-monorepo/dev/shared-working-context/reports/mocks-registry.md
T
2026-07-10 18:46:16 +03:30

55 KiB
Raw Blame History

Mock & integration registry

The master list of every external dependency that is mocked behind a DI seam in this build, and the exact steps to make each one real. Backend lane owns this file; every phase that introduces or touches a seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to "production with real providers".

Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live.

Seam (interface) Introduced in What it fakes Config keys Make it real → Status
ISmsSender backend-phase-2 OTP/SMS delivery — LoggingSmsSender (Baya.Infrastructure.CrossCutting/Seams/) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in AddCrossCuttingSeams none today; real client will need Seams:Sms:ApiKey + Seams:Sms:SenderLine (+ gateway base URL) 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to Directory.Packages.props; 2) implement ISmsSender.SendOtpAsync/SendAsync against it (template/pattern-based OTP send); 3) bind the new Seams:Sms options; 4) swap the registration in AddCrossCuttingSeams (config-selected) — handlers unchanged; 5) keep the per-phone resend window + otp rate-limit policy exactly as-is; test with a real SIM 🟡
IObjectStorage backend-phase-0/6 File storage — local-disk store under a scratch root (LocalDiskObjectStorage, Baya.Infrastructure.CrossCutting/Seams/) Seams:ObjectStorage:RootPath (default: temp dir) Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds 🟡
ICacheService backend-phase-0 Caching — in-memory IMemoryCache (MemoryCacheService, Baya.Infrastructure.CrossCutting/Seams/) none Swap to Redis (StackExchange.Redis); keep key/TTL scheme 🟡
IDistributedLock backend-phase-10 Money-path locks — no-op/in-proc tbd Redis lock (RedLock); DB constraint remains the backstop 🔴
INurseSearch backend-phase-7 Search — SQL over nurse_search_index tbd Elasticsearch index + feeder; reimplement the interface 🔴
IPaymentProvider backend-phase-10 Card PSP/IPG — deterministic success tbd ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم 🔴
ISettlementSplitProvider backend-phase-10 تسهیم split — accepts any balanced legs tbd Provider split-by-ratio to registered Shebas 🔴
IWebhookVerifier backend-phase-10 Callback auth — always valid tbd Per-provider HMAC/signature + server-side re-verify 🔴
IBnplProvider backend-phase-12 BNPL — MockBnplProvider drives the full state machine (eligible→settled→reverted), settle returns order commission% Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission} SnappPay/Digipay OAuth + verb set; encrypted creds in payment_gateways.config_json 🟡
IBnplProviderResolver backend-phase-12 Per-provider_code selection — maps every known code to the one mock none One concrete adapter per code; resolver returns the right one 🟡
ICurrencyNormalizer backend-phase-12 Toman↔IRR — ×10 at the boundary Seams:Currency:TomanToIrrMultiplier (default 10) Config-driven per provider boundary 🟡
IBankTransferProvider backend-phase-13 PAYA/SATNA payout rail — MockBankTransferProvider (Baya.Infrastructure.CrossCutting/Seams/), no external call, no money moves: SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey) returns a deterministic externalBatchRef + a per-instruction transfer_reference and settles every row Paid (collapsing the real submitted → paid reconciliation); it honours the PAYA/SATNA method the handler chose by the payout_satna_threshold_irr config and echoes it. A config switch forces deterministic failures so partially_failed/retry are testable: ForceFailure fails the whole batch, FailIban fails one destination. GetPayoutStatusAsync echoes Paid. Registered singleton in AddCrossCuttingSeams Seams:BankTransfer:ForceFailure (default false), Seams:BankTransfer:FailIban (default empty) 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to Directory.Packages.props; 2) add Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}; 3) implement SubmitPayoutBatchAsync to register the batch against the registered source settlement account and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's verified Sheba (the b3 matched_national_id gate), honouring batch caps/minimums; 4) implement the async reconciliation callback that flips a payout submitted → paid/failed (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry 🟡
IHolidayCalendar backend-phase-1 Bank holidays — reads the seeded ops.IranianHolidays table; lookups cached (HolidayCalendarService, Persistence/Services/Holidays/); Iranian banking weekend = Friday none Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays 🟡
IAnalyticsSink backend-phase-1 Behavioural events — inserts an ops.SystemEvents row, fire-and-forget (AnalyticsSink, Persistence/Services/Analytics/) none Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics 🟡
IJobScheduler (retention + booking expiry) backend-phase-1 Scheduling — in-process interval BackgroundServices: PurgeOldReadNotifications daily (NotificationRetentionHostedService, Persistence/Services/Notifications/) and b8 BookingRequestExpiryHostedService (Persistence/Services/Booking/) running the idempotent booking-request expiry sweep every minute none Swap to Hangfire/Quartz; register both jobs there; keep the purge predicate (is_read=1 AND age>90d) and the booking-expiry command 🟡
IShahkarVerifier backend-phase-6 شاهکار phone↔national-id binding — MockShahkarVerifier (Baya.Infrastructure.CrossCutting/Seams/) returns a deterministic result + fake vendor ref + external_response_json: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a shared_sim support alert) and the mismatch national id (→ plain mismatch); registered singleton in AddCrossCuttingSeams. No real Shahkar call Seams:Shahkar:SharedSimPhone (default 09120000000), Seams:Shahkar:MismatchNationalId (default 1111111111) 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to Directory.Packages.props; 2) add Seams:Shahkar:{ApiKey,BaseUrl} options; 3) implement MatchAsync(phone, nationalId) against the real استعلام شاهکار, mapping to ShahkarMatchResult and persisting the raw response into the step's external_response_json; 4) keep shared-SIM as the explicit handled failure (IsSharedSim=true); 5) swap the registration in AddCrossCuttingSeams (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (shahkar_verified_at resets upstream on phone change) 🟡
IIdentityKycProvider backend-phase-6 Identity KYC (national-id validity + name match + liveness) — MockIdentityKycProvider (.../Seams/) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + external_response_json; on pass the handler populates users.national_id + national_id_verified_at. No real OCR/liveness; registered singleton Seams:IdentityKyc:FailNationalId (default 0000000000), Seams:IdentityKyc:MatchedName (default Verified Nurse) 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to Directory.Packages.props; 2) add Seams:IdentityKyc:{ApiKey,BaseUrl} options; 3) implement VerifyAsync(nationalId, livenessPayload) → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to IdentityKycResult and persisting external_response_json; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that national_id is populated only on pass 🟡
ICredentialVerifier backend-phase-6 MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — MockCredentialVerifier (.../Seams/) is the manual-admin default: every call returns RequiresManualReview with verification_method=manual (an admin verifies the uploaded document against the official portal in AdminReviewStep). No portal call; registered singleton. There is no public B2B API for MoH/INO, so this stays manual until one appears none 1) when an MoH/INO portal or API becomes available, implement VerifyAsync(credentialType, credentialNumber) to return Verified/Failed with `verification_method=portal api(+external_response_json); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. MoH/INO have no public B2B API today
IBankAccountOwnershipVerifier backend-phase-3 استعلام شبا IBAN-owner ↔ national-id inquiry — MockBankAccountOwnershipVerifier (Baya.Infrastructure.CrossCutting/Seams/) returns a deterministic fake: every IBAN matches (matched_national_id=true, echoes a holder name + MOCK-SHEBA-{sha} vendor ref) except the configured mismatch IBAN which returns false; registered singleton in AddCrossCuttingSeams. No real bank/KYC call, no money moves Seams:BankOwnership:MismatchIban (default IR000000000000000000000000), Seams:BankOwnership:MatchedHolderName, Seams:BankOwnership:MismatchHolderName 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to Directory.Packages.props; 2) add Seams:BankOwnership:{ApiKey,BaseUrl} options; 3) implement VerifyOwnershipAsync(iban, nurseNationalId) against the real Sheba-owner inquiry, mapping to OwnershipInquiryResult; 4) persist the real ownership_vendor_ref (+ raw response if a column is added); 5) swap the registration in AddCrossCuttingSeams (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours matched_national_id=true 🟡
IGeocoder backend-phase-4 Address→lat/lng — MockGeocoder (Baya.Infrastructure.CrossCutting/Seams/) returns deterministic decimal coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus formatted_address + confidence; no network call. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in AddCrossCuttingSeams Seams:Geocoding:ReturnNullCoordinates (default false), Seams:Geocoding:LowConfidenceMarker (default NO_GEO), Seams:Geocoding:ResolvedConfidence (default 0.9) 1) pick Neshan (or Google) geocoding, add its client package to Directory.Packages.props; 2) add Seams:Geocoding:{ApiKey,BaseUrl} options; 3) implement IGeocoder.GeocodeAsync(addressText, cityName, districtName?) against it, mapping to (lat, lng, formatted_address, confidence) with decimal coords; 4) add rate-limit/retry; 5) swap the registration in AddCrossCuttingSeams (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds 🟡
IMoadianClient backend-phase-11 سامانه مودیان e-invoice — leaves ref pending tbd Real مودیان submission → 22-digit ref 🔴
IReviewModerationService backend-phase-14 AI review pre-screen — MockReviewModerationService (Baya.Infrastructure.CrossCutting/Seams/), no external call: ScreenAsync(reviewText) returns a ModerationVerdict(Decision, Reason) — a banned-word substring hit → Reject (banned_word:{w}); otherwise clean text → a human-review Flag by default (so the publish gate holds), or Approve when AutoApproveClean is set. The SubmitReview handler maps the verdict to the initial status (Approve→published, Reject→hidden, else pending) — decision authority stays with ModerateReviewCommand (human override). Registered singleton in AddCrossCuttingSeams Seams:ReviewModeration:AutoApproveClean (default false), Seams:ReviewModeration:BannedWords (default scam,fraud,کلاهبردار) 1) pick a text classifier / LLM moderation endpoint, add its client package to Directory.Packages.props; 2) add Seams:ReviewModeration:{ApiKey,BaseUrl} options; 3) implement ScreenAsync(reviewText) → map the provider's toxicity/spam scores to Approve/Flag/Reject + a reason; 4) swap the registration in AddCrossCuttingSeams (config-selected) — SubmitReviewCommand/ModerateReviewCommand unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a Flag 🟡
IFieldEncryptor backend-phase-0 PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (SymmetricFieldEncryptor, Baya.Infrastructure.CrossCutting/Seams/) Seams:FieldEncryption:Key, Seams:FieldEncryption:HashKey KMS / column encryption / Key Vault / HSM 🟡
INotificationDispatcher backend-phase-0/1 Notification channels — in-app write is now real (InAppNotificationDispatcher, Persistence/Services/Notifications/, writes an ops.Notifications row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam none Add SMS (ISmsSender) / push (FCM) channels; polling → Redis pub/sub or SignalR later 🟡
ILicenseVerificationService backend-phase-15 eNamad / MoH establishment-permit — manual approve tbd Real registry/API 🔴
IPaymentCaptureSimulator backend-phase-9 The temporary conversion trigger standing in for b10's real card capture. MockPaymentCaptureSimulator (Baya.Infrastructure.CrossCutting/Seams/) returns a deterministic succeeded capture (a fake gateway_reference + a configurable psp_fee_amount) so ConvertRequestToBookingCommand is exercisable now; a config switch forces a failed capture (→ no booking is created). This is the trigger, not a parallel money path — registered singleton in AddCrossCuttingSeams Seams:PaymentCapture:ForceFailure (default false), Seams:PaymentCapture:PspFeeAmount (default unset) In b10: 1) build the real card capture (payment_transactions, PSP/IPG client, webhook verify); 2) on a real payment_transactions.succeeded, call ConvertRequestToBooking directly (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the IPaymentCaptureSimulator registration + MockPaymentCaptureSimulator; the conversion/idempotency logic is unchanged 🟡
INurseSearch backend-phase-7 The search-service seam (read side). The MVP impl SqlNurseSearch (Persistence/Services/Search/) is REAL, not a mock — it reads the maintained nurse_search_index WHERE is_searchable=1, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & AsNoTracking. Registered by AddPersistenceServices, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt Search:Backend (default sql; any other value throws until Elastic ships) 1) add an Elasticsearch client package (Elastic.Clients.Elasticsearch) to Directory.Packages.props; 2) define the index mapping (the NurseSearchResultDto fields + is_searchable); 3) implement ElasticNurseSearch : INurseSearch (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the ISearchIndexMaintainer change events via an outbox/CDC stream into ES (see the next row); 5) point Search:Backend=elastic in config — callers unchanged; 6) keep the SQL index as the projection/fallback + the reconciliation source (RebuildAsync); 7) test filter/sort/paging parity vs SqlNurseSearch 🟢 SQL real; Elastic 🟡
IPaymentProvider backend-phase-10 Card PSP acquirer — MockPaymentProvider (Baya.Infrastructure.CrossCutting/Seams/), no external call: InitPaymentAsync → a deterministic gatewayReferenceCode (mock-ref-{requestId}-{key}) + a fake redirect URL; VerifyAsync → instant Succeeded echoing the expected amount (the server-side re-check); RefundAsync(ref, amount, idempotencyKey, ct) → always Succeeded, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in AddCrossCuttingSeams none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from payment_gateways.config_json (encrypted), not appsettings 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to Directory.Packages.props; 2) implement InitPaymentAsync (open the IPG session, return the Shaparak-routed redirect + reference), VerifyAsync (the mandatory server-side verify re-check of amount + reference — never trust the callback alone), RefundAsync; 3) read merchant id/terminal from the encrypted payment_gateways.config_json; 4) a config-driven IProviderRegistry/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into gateway_response_json; 6) swap the registration (config-selected) — handlers unchanged 🟡
ISettlementSplitProvider backend-phase-10 تسهیم settlement-sharing — MockSettlementSplitProvider (.../Seams/) records the split intent and returns Settled for any legs whose sum is positive; the platform never moves money. Registered singleton in AddCrossCuttingSeams none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config 1) pick the acquirer's تسهیم API, implement RegisterSplitAsync(bookingId, legs) to register the split-by-ratio to each beneficiary's registered IBAN (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from nurse_bank_accounts (the b3 matched_national_id gate) and the platform SHEBA from config; 3) GetSplitStatusAsync polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) 🟡
IWebhookVerifier backend-phase-10 PSP callback signature verify — MockWebhookVerifier (.../Seams/) treats the signature as valid unless the body carries Seams:Payments:InvalidSignatureMarker, and extracts external_event_id/event_type/gateway_reference_code from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in AddCrossCuttingSeams Seams:Payments:InvalidSignatureMarker (default INVALID_SIGNATURE) 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side verify re-check (amount + reference) via IPaymentProvider.VerifyAsync; 3) parse the real provider event shape into WebhookVerification; 4) swap the registration (config-selected) — the HandlePaymentWebhook upsert-first/no-op-on-duplicate ordering is unchanged 🟡
IDistributedLock backend-phase-10 Money-path mutex — InProcessDistributedLock (.../Seams/): a per-key SemaphoreSlim so the capture path runs the same acquire/release shape it will with real Redis, within one process only. Not a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in AddCrossCuttingSeams none today; real client needs a Redis connection string 1) add StackExchange.Redis to Directory.Packages.props; 2) implement AcquireAsync(key) with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention booking:{id}:payment; 3) bind Seams:Payments:Redis (or reuse the ICacheService Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired 🟡
ISearchIndexMaintainer (the "ISearchIndexWriter" event shape) backend-phase-7 The index-maintenance seam (write side). The inline SQL path is REALSearchIndexMaintainer (Persistence/Services/Search/) re-derives nurse_search_index from source and stages it inside the owning source write's unit of work (single CommitAsync), invoked from the b3/b4/b5/b6 handlers (ReindexVariantAsync/ReindexNurseAsync/FanOutServiceAreaAsync/RemoveServiceAreaRowsAsync/RebuildAsync). Only the outbox/queue routing for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert none 1) introduce an outbox table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to ElasticNurseSearch; 3) keep the inline SQL upsert as the projection/fallback so RebuildAsync stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path 🟡 outbox deferred (inline real)

| IMoadianClient | backend-phase-11 | سامانه مودیان e-invoicing — MockMoadianClient (Baya.Infrastructure.CrossCutting/Seams/), no external call: SubmitAsync leaves a new invoice moadian_status = pending with moadian_reference_number = null; a config switch forces a deterministic registered result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in AddCrossCuttingSeams | Seams:Moadian:ForceRegistered (default false) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement SubmitAsync to POST the معاملات/invoice (صورتحساب) to the مودیان API, sign the payload, map the 22-digit reference_number; 3) walk the async pending → submitted → registered/failed states via a reconciliation callback/poll (cron deferred/manual today — a job flips moadian_status + fills the ref); 4) swap the registration (config-selected) — the IssueInvoice handler is unchanged | 🟡 | | IBnplProvider | backend-phase-12 (superset of the b11 revert-only stub) | BNPL provider — MockBnplProvider (Baya.Infrastructure.CrossCutting/Seams/), no external call, drives the full SnappPay-superset verb set CheckEligibilityAsync/CreatePaymentTokenAsync/VerifyAsync/SettleAsync/GetStatusAsync/CancelAsync/RevertAsync/UpdateAsync and the eligible → token_issued → verified → settled → reverted/cancelled machine. Eligibility is eligible unless the mobile = NotEligibleMobile (→not_eligible) or the order exceeds CreditCeilingIrr (→ceiling_exceeded); token/redirect are deterministic; settle returns settledAmountIrr = order round(order × CommissionRate) + the commission read from the response (never hardcoded) + a nullable settledAt (null when SettlementInstant=false, modelling non-instant settlement); revert echoes a deterministic external_revert_reference + nullable provider_commission_reversed_amount. Selected per provider_code by IBnplProviderResolver (MockBnplProviderResolver → the one mock for every known code); the b11 refund bnpl_revert path still injects IBnplProvider directly. Registered singleton in AddCrossCuttingSeams | Seams:Bnpl:CommissionRate (default 0.10), Seams:Bnpl:SettlementInstant (default true), Seams:Bnpl:CreditCeilingIrr (default 2000000000), Seams:Bnpl:NotEligibleMobile (default 09120000099), Seams:Bnpl:ForceFailure (default false), Seams:Bnpl:ReverseProviderCommission (default false) | 1) implement one concrete adapter per provider_code (SnappPay OAuth api/online/v1/oauth/token + offer/v1/eligible + payment/v1/token\|verify\|settle\|revert\|cancel\|update\|status, or Digipay UPG tickets/business?type=13 + purchases/verify + purchases/deliver?type=13 + refunds/reverse); 2) read credentials from the encrypted payment_gateways.config_json; 3) do Toman↔Rial via ICurrencyNormalizer at the adapter boundary; 4) read the per-contract commission from the settle response, never hardcode; 5) map the provider event shape into the callback so HandleBnplCallback dispatch is unchanged; 6) register per-code in IBnplProviderResolver (config-selected) — handlers unchanged. Warn: do NOT use the unrelated Canadian SnapPayInc/open-api-java-sdk. | 🟡 | | ICurrencyNormalizer | backend-phase-12 | Toman↔IRR at the provider boundary — MockCurrencyNormalizer (Baya.Infrastructure.CrossCutting/Seams/): ToIrr(amount,"TOMAN") = amount × TomanToIrrMultiplier, IRR passes through; ToDisplayToman divides back. Conversion happens ONLY here, never internally. Registered singleton in AddCrossCuttingSeams | Seams:Currency:TomanToIrrMultiplier (default 10) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 | | INursePayoutStatus | backend-phase-11 (interim) → backend-phase-13 (authoritative) | "Was the nurse already paid for this booking?" — b13 shipped the real NursePayoutLinkStatusService (Persistence/Services/Payments/): a booking is paid iff a nurse_payout_booking_links row ties it to a nurse_payouts row in status paid. This supersedes the interim NursePayoutStatusService (dispute-window derivation, now deleted); the refund_assume_nurse_paid config override still forces the paid answer for ops/testing. Not a mock of an external — a real ledger-backed derivation. Registered scoped in AddPersistenceServices. The refund pre-payout/clawback fork is unchanged | refund_assume_nurse_paid (platform_configs, default false) | Nothing further — this is the real implementation. (A future on-demand-withdrawal model would extend the "paid?" definition, not replace it.) | 🟢 |

| ILicenseVerificationService | backend-phase-15 | Partner-center licensing (eNamad / MoH establishment-permit پروانه تأسیس) — MockLicenseVerificationService (Baya.Infrastructure.CrossCutting/Seams/), no external call: VerifyEstablishmentPermitAsync/VerifyENamadAsync return NeedsManualReview (no automated registry → a human admin decides), so VerifyPartnerCenterCommand records the manual approval and activates the center. A config toggle makes clean checks return Valid (auto-approve path); an explicit Invalid verdict blocks activation. Registered singleton in AddCrossCuttingSeams | Seams:LicenseVerification:AutoApprove (default false) | 1) obtain access to a real eNamad status endpoint and/or the MoH establishment-permit registry (no public B2B API today — likely a manual/partner data feed at launch); 2) implement the two methods to look up the permit/eNamad code and return Valid/Invalid + a reason; 3) swap the registration (config-selected) — VerifyPartnerCenter is unchanged (it keeps the human-override decision authority) | 🟡 |

Exact config keys and file paths get filled in by the phase that builds each seam. Keep the "Make it real →" column actionable enough that a developer can pick up any single row and ship it.

Frontend client-side mocks (not backend DI seams)

These are in-browser mocks behind a services/{domain} interface, selected by a config flag. They exist so the frontend can build before the backend phase merges, and swap to the real HTTP client in one line.

Seam (interface) File What it fakes Config flag Make it real → Status
PatientsApi client/src/services/patients/apis/mockApi.ts In-memory patient CRUD (list/get/create/update/soft-archive), seeded empty so onboarding + the empty state both demo; persists the client-augmented relation/conditions the wire PatientDto lacks (REQ-005) USE_PATIENTS_MOCK (services/patients/constants.ts, default true) Deliver REQ-005 (relation/conditions on PatientDto + create/update), then set flag falsepatientsClientApi is already wired to the b3 patients/* routes 🟡
ProfilesApi client/src/services/profiles/apis/mockApi.ts Customer + nurse profile get/upsert and avatar upload (echoes an object-URL). Keeps guarded read-only fields (isVerified=false, zero aggregates). Augments customer name/language (REQ-007) + nurse avatarUrl (REQ-006) the wire DTOs lack USE_PROFILES_MOCK (services/profiles/constants.ts, default true) b3 customer_profiles/* + nurse_profiles/* are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag falseprofilesClientApi is wired (its uploadAvatar throws 501 until REQ-006) 🟡
NurseBankAccountsApi client/src/services/nurse/apis/mockApi.ts Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا pending→verified/mismatch transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (IR000000000000000000000000, matches backend default) resolves to matchedNationalId=false USE_NURSE_BANK_MOCK (services/nurse/constants.ts, default true) b3 nurse_bank_accounts/* are live (the real add resolves the inquiry synchronously — no client poll needed); set flag falsenurseBankClientApi is wired 🟡
AuthApi client/src/services/auth/apis/mockApi.ts (authMockApi) Phone-OTP login offline: requestOtp{otpSent,resendAvailableInSeconds:120}; verifyOtp accepts dev code 123456 and locks after 3 wrong tries (otp_locked); getMe/selectRole/refresh from a MOCK_SCENARIO toggle (customer/nurse_unverified/no_role) to exercise all router branches USE_AUTH_MOCK (services/auth/constants.ts, default false — b2 is live) + MOCK_SCENARIO in mockApi.ts The real authClientApi is already wired to the live b2 routes; set USE_AUTH_MOCK = false (already the default) — no hook/screen change 🟢 real by default, 🟡 mock available
GeographyApi client/src/services/geography/apis/mockApi.ts (+ apis/seed.ts) The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, sortOrder-ordered. seed.ts also resolves a saved cityId/districtId back to names for the addresses & serviceAreas mocks USE_GEOGRAPHY_MOCK (services/geography/constants.ts, default true) b4 geo/{provinces,cities,districts} are live; set flag falsegeographyClientApi is wired to the snake_case-param lookups. No hook/component change 🟡
AddressesApi client/src/services/addresses/apis/mockApi.ts Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the exactly-one-primary invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented provinceId (REQ-009) and the picked latitude/longitude (REQ-008) the wire DTO/create-body lack USE_ADDRESSES_MOCK (services/addresses/constants.ts, default true) b4 customer_addresses/* are live; deliver REQ-008 (accept the pin) + REQ-009 (provinceId on the DTO), then set flag falseaddressesClientApi is wired (sends the pin + pageSize, echoes provinceId locally) 🟡
ServiceAreasApi client/src/services/serviceAreas/apis/mockApi.ts Nurse coverage areas (list whole-city-first / add / remove). Enforces UNIQUE(cityId, districtId) exactly as the server — a duplicate (incl. a second whole-city row) throws the same 409 (area_duplicate) so the coverage editor's inline dup handling is demonstrable USE_SERVICE_AREAS_MOCK (services/serviceAreas/constants.ts, default true) b4 nurse_service_areas/* are live; set flag falseserviceAreasClientApi is wired (maps the server 409 to the same inline message). No hook/component change 🟡
AddressMapPicker (map stand-in) client/src/components/geography/AddressMapPicker.tsx Not a real map — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to { latitude, longitude } around the chosen city's centroid (CITY_CENTROIDS/IRAN_CENTROID in services/geography/constants.ts). Emits real coordinates for the create/update request none (component boundary) Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same { latitude, longitude } via onChangeAddressForm and every caller stay unchanged 🟡
CatalogApi client/src/services/catalog/apis/mockApi.ts (+ apis/seed.ts) The catalog skeleton + nurse pricing layer. Categories mirror the b5 seed exactly (5 categories, ids 15, sortOrder 04). Seeds representative option groups/values the fresh backend does not (an admin authors them per category) — incl. required + optional groups and one cross-category (serviceCategoryId=null) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: 400 missing required dimension / bad price, and the (nurse, category, option-set) duplicate 409 (via optionSetSignature). Variant store seeded empty so the offerings empty-state demos; the nurse builds variants live (across price units). create/update/set_active/list(active-first, paginated)/get. Money stays an IRR digit-string end-to-end USE_CATALOG_MOCK (services/catalog/constants.ts, default true) b5 catalog/* + nurse_variants/* are live; set flag falsecatalogClientApi is wired to the action-style routes (camelCase bodies, pageSize pagination per REQ-010, category_id snake_case filter). When swapped, categories will have NO option groups until an admin authors them (the mock's groups were illustrative). No hook/component change 🟡
VerificationApi client/src/services/verification/apis/mockApi.ts The whole nurse trust journey (b6). Seeds the six required steps on start (idempotent); runIdentityKyc passes any well-formed 10-digit id except 0000000000 (→ failed/kyc_no_match, matches backend MockIdentityKycProvider); runShahkarMatch requires identity passed, fails shared-SIM when the bound national id is 1111111111 (→ failed/shared_sim); runBankVerification passes (assumes a primary bank account); uploadStepDocument simulates signed-URL PUT progress then moves the step to in_review (metadata only); submitCredentialDetails validates the INO number. Re-aggregates like the server (approved only when every step passes). Dev-only __mockApproveAll()/__mockRejectStep(code,reason) stand in for the deferred (f15) admin review queue so a human can watch is_verified/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true USE_VERIFICATION_MOCK (services/verification/constants.ts, default true) b6 nurse_verification/* + nurses/{id}/trust_badge are live; set flag falseverificationClientApi is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). Caveat: the real submitCredentialDetails no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change 🟡
BookingsApi client/src/services/bookings/apis/mockApi.ts The post-payment engagement (b9). Seeds 2 confirmed bookings (one 3-session multi-day, one single-visit) + booking_care_instructions + a per-session EVV state machinecheckInVisit flips the session→in_progress/checked_in (booking→in_progress) and computes the advisory checkInAddressMatch (haversine vs the seeded address ± MOCK_EVV_TOLERANCE_METERS, null when GPS was absent); checkOutVisit requires an open check-in (400 no_open_check_in otherwise), completes the session (stamps payoutEligibleAt), and completes the booking + opens the dispute window once all sessions settle. getCareInstructions 404s any viewer but the assigned nurse (the two-stage-disclosure boundary; the UI enabled gate means the customer never even calls it). Money stays IRR digit-strings with gross = commission + payout and Σ visitPayout = payout USE_BOOKINGS_MOCK (services/bookings/constants.ts, default true) b9 bookings/* + booking_sessions/* are live, but a booking only exists after bookings/convert runs on a paid request — both upstreams (bookingRequests mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag falsebookingsClientApi maps the routes 1:1 (+ bookingsServerApi for the RSC prefetch). No hook/component change 🟡
ILocationProvider client/src/services/bookings/evv/locationProvider.ts EVV GPS capture — the only client seam f8 introduces. getCurrentPosition() never rejects (denied/unavailable → null, so a GPS problem is advisory, never a block). The real provider wraps navigator.geolocation.getCurrentPosition; the mock returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock BookingsApi computes the match against the same seeded reference point) NEXT_PUBLIC_EVV_MOCK_GPS = in_range | out_of_range | denied | off (default in_range while USE_BOOKINGS_MOCK, else off) Set NEXT_PUBLIC_EVV_MOCK_GPS=off (or flip USE_BOOKINGS_MOCK) → the real navigator.geolocation provider is selected. Real address-match math stays server-side (backend geocoding seam), not here — this seam only captures the position 🟡
PaymentApi client/src/services/payment/apis/mockApi.ts The f9 checkout money path — plays the PSP + webhook roles the client can't reach: getCheckoutSummary serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via integer parts-per-10000 BigInt math, 12% fee / 10% VAT, reconciles to the rial); initiatePayment enforces b10 idempotency (same Idempotency-Key → same attempt; repeat after capture / lapsed window → 409) and returns a redirectUrl into the local mock-gateway harness; confirmGatewayReturn on success is the webhook-confirm stand-in and the missing f7↔f8 bridge — flips the request converted (+ client-augmented bookingId, via mockMarkBookingRequestConverted in the f7 mock), inserts a confirmed booking into the f8 store (mockInsertConvertedBooking), and auto-issues the b11-shaped invoice (moadianStatus: pending, pdfUrl: null so the print path exercises); replayed returns converge idempotently; getInvoice 404s until issued USE_PAYMENT_MOCK (services/payment/constants.ts, default true) b10 initiate + b11 invoice are live and paymentClientApi maps them 1:1 (Idempotency-Key header, GET invoices/{bookingId}); deliver REQ-016 (checkout summary — the real client already targets the proposed booking_requests/checkout_summary/{id} slug) + REQ-017 (transaction status / bookingId; until then the real outcome poll maps booking_requests/get statuses and can't distinguish declined from slow) + REQ-018 (invoice reachable post-capture), make the upstream bookingRequests flow real, then set flag false. No hook/component change 🟡
Mock-gateway page (test harness) client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx Not a product feature — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock redirectUrl points here, and its success/failure buttons drive both branches of the return surface (?outcome=success|failure). Clearly labelled «درگاه پرداخت آزمایشی», dashed border none — only reachable via the mock's redirectUrl On the real path b10's redirectUrl is the PSP's absolute URL (the checkout does a full window.location.assign for http(s) URLs), so this page is simply never linked; delete it when USE_PAYMENT_MOCK retires. The PSP's return deep-link into /bookings/checkout/return is backend/PSP config 🟡
RefundsApi client/src/services/refunds/apis/mockApi.ts The f10 customer cancel + refund surface b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared f8 bookings store (mockGetBookingForRefund) to resolve the tier by lead time (free_24h >24h / partial_under_24h <24h / customer_no_show started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via integer parts-per-10000 BigInt math (refundAmount + fee = refundableGross to the rial). cancelBooking flips the booking → cancelled (mockMarkBookingCancelled stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: card → succeeded immediately (no ETA); BNPL → approvedprocessingsucceeded over status polls with a expected_customer_refund_eta ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy 409 (already-cancelled / nothing-refundable / non-refundable session). Seeds a failed refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports USE_REFUNDS_MOCK (services/refunds/constants.ts, default true) Deliver REQ-019 (customer cancel command — the real refundsClientApi.cancelBooking already targets POST bookings/{id}/cancel) + REQ-020 (cancellation-policy preview → GET bookings/{id}/cancellation_policy, incl. the canonical cancellation_policy_code set) + REQ-021 (GET refunds/by_booking/{id} + the decomposition fields on the customer refunds/{id}/status), then set flag false — the real client maps the published refunds/{id}/status 1:1 and targets the proposed slugs for the rest. No hook/component change 🟡
BnplApi client/src/services/bnpl/apis/mockApi.ts The f11 BNPL installment checkout (D1D5) b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and explicitly does not model the repayment schedule; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared f7 store and plays the provider: getBnplOptions builds the provider set as data (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via integer parts-per-10000 BigInt math, never a hardcoded fee in the UI); checkEligibility returns eligible unless the national-id last digit is 0 (→not_eligible) or the order exceeds MOCK_CREDIT_CEILING_IRR (→ceiling_exceeded) so both declined paths demo; getBnplSchedule serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); issueBnplToken enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → 409) + a redirectUrl into the local provider-handoff harness; acceptBnplSchedule on success is the settle stand-in and reuses the f9 conversion bridge — flips the request converted (mockMarkBookingRequestConverted), inserts a confirmed booking (mockInsertConvertedBooking; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and seeds a provider-reported Wallet plan; getWalletInstallments serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) USE_BNPL_MOCK (services/bnpl/constants.ts, default true) Deliver REQ-022 (options + schedule — real bnplClientApi targets checkout_bnpl/options/{id} + checkout_bnpl/schedule/{id}), REQ-023 (eligibility accepts the D3 national-id/mobile/consent), REQ-024 (checkout_bnpl/wallet_installments provider-reported status + a customer bookingId on the settled order), and make the upstream bookingRequests flow real, then set flag falsecheckEligibility/issueBnplToken(Idempotency-Key)/getBnplOrder already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change 🟡
BNPL provider-handoff harness (test harness) client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx Not a product feature — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock redirectUrl points here, and its pay/cancel buttons drive both branches of the return surface (?outcome=success|failure). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border none — only reachable via the mock's redirectUrl On the real path b12's redirectUrl is the provider's absolute URL (the wizard does a full window.location.assign for http(s)), so this page is never linked; delete it when USE_BNPL_MOCK retires. The provider's return deep-link into /bookings/checkout/bnpl/return is backend/provider config 🟡
PayoutsApi client/src/services/payouts/apis/mockApi.ts The f12 nurse earnings surface b13 doesn't serve read-side for a nurse (b13's only nurse route is GET nurse_payouts/history; the four-bucket earnings summary, the per-booking earnings list + money-state, and a nurse-readable payout detail with batch context + booking links are gaps → REQ-025). Self-contained, money-correct fixtures exercising every UI state: all four earnings states (pending/eligible/paid/clawback_applied; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four PayoutStatus values in history (pending/submitted/paid/failed, incl. a failed payout with failureReason: 'invalid_sheba' for the read-only failure banner), payout details that reconcile (gross clawback = net = amount, Σ booking-link amounts = grossEarnings), and a signed net balance computed with BigInt via a MOCK_SCENARIO toggle (standard = positive; clawback_heavy = negative "owed back" for phase §7 step 3). Timestamps are relative to now so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). getNurseEarnings filters by state + paginates USE_PAYOUTS_MOCK (services/payouts/constants.ts, default true) + MOCK_SCENARIO in constants.ts Deliver REQ-025 (earnings_balance + earnings list + nurse nurse_payouts/{id} detail + failureReason on the history DTO), then set flag falsepayoutsClientApi already maps the live GET nurse_payouts/history 1:1 and targets the proposed slugs for the other three. No hook/component change 🟡
ReviewsApi client/src/services/reviews/apis/mockApi.ts The f13 moderated-review trust loop. b14 serves the review submit (POST bookings/{id}/review), the public nurse reviews page (GET nurses/{id}/reviews), and the tag rollup — those are mapped 1:1 in reviewsClientApi. But there is no review-eligibility read and no my-review-for-booking read (REQ-026), and the whole moderation transition (pending_moderation → published) is admin-only (f15). The mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate eligibility on a completed/closed booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as pending_moderation so eligibility flips already_reviewed + getMyReviewForBooking returns the persistent "under review" state, and seeds a published list per nurse (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is recomputed from the published list (never a stored sum). A submitted review never enters any public list. Dev-only __mockPublishSubmittedReview(bookingId) stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free USE_REVIEWS_MOCK (services/reviews/constants.ts, default true) Deliver REQ-026 (review_eligibility + my_review reads; confirm masked-author omission), then set flag falsereviewsClientApi.getNurseReviews/createReview already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is f15 (admin). No hook/component change 🟡
PatientRecordsApi client/src/services/patientRecords/apis/mockApi.ts The f13 continuity-of-care surface. Two very different things: (1) the nurse-authored visit-note history (getPatientHistory/createVisitNote) is REAL b14 (GET/POST patients/{id}/care_records), mapped 1:1 in patientRecordsClientApi (the append composes the ticked task checklist into the note body since the wire has no structured task field); (2) the family-owned editable record (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the access check have NO backend at all (neither the b14 contract nor data-model/10-reviews-and-records.md model them → REQ-027). The mock is patient-scoped and lazily seeds a coherent default per patient: a default family record (customer edits it), a multi-nurse continuity history (two prior notes from different nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a foreign-patient access-denied path (MOCK_FOREIGN_PATIENT_ID = 8888canView:false + a 403 on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) USE_PATIENT_RECORDS_MOCK (services/patientRecords/constants.ts, default true) Deliver REQ-027 (family-owned care_record GET/PUT + record_access + structured taskResults), then set flag false — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity 🟡
f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads client/src/services/bookings/apis/mockApi.ts Non-seam additions (mirrors the f10 refunds precedent). The f8 seeds had no completed booking (only confirmed/in_progress/cancelled), so f13's review flow needs one: added booking 5005 (status: 'completed', nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a cross-mock read helpermockGetBookingForReview(id) (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the listBookings seam row omits patientId/nurseId). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle — (part of USE_BOOKINGS_MOCK) When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks 🟡
TicketsApi client/src/services/tickets/apis/mockApi.ts The f14 ticket channel (b15). b15 serves open/list/thread/message and ticketsClientApi maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks unreadCount/lastMessageAt (REQ-028), so the mock is primary. It seeds 3 tickets (a booking-5001 coordination ticket with a stored internal admin note the user view NEVER returns — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that clears on open; openTicket is idempotent for coordination + bookingId (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; postMessage appends as the current viewer (tracked from the last getTicket so an optimistic message reconciles as mine in whichever app is open), throws 403 on a closed ticket, and throws 500 on the dev sentinel body '/fail' (the optimistic failure→retry path). MOCK_VIEWER_USER_ID (per-role "me") drives isMine; isInternal is never modelled in the user-app types USE_TICKETS_MOCK (services/tickets/constants.ts, default true) Deliver REQ-028 (unreadCount/lastMessageAt on the summary + a by-booking user lookup + optional author name + optional clientMessageId idempotency) and make the upstream bookings flow real, then set flag falseticketsClientApi already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change 🟡
NotificationsApi client/src/services/notifications/apis/mockApi.ts The f14 notification center + polled bell (b1). The b1 endpoints are live and notificationsClientApi maps them 1:1, but a notification only exists once some other backend domain dispatches one (INotificationDispatcher) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic unread-first feed spanning every deep-link class (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case dataJson string the list maps through the real parseNotificationData; getUnreadCount/markRead/markAllRead mutate the in-memory feed. Dev-only __mockPushNotification(type,title,dataJson?,body?) prepends a fresh unread row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen USE_NOTIFICATIONS_MOCK (services/notifications/constants.ts, default true) When the upstream domains dispatch real notifications, set flag falsenotificationsClientApi already maps the live b1 notifications/* routes 1:1 (page/pageSize, {count}, {notificationId}). No hook/component change 🟡