38 KiB
Balinyaar Server — Claude Code Guidelines
The backend API of Balinyaar, a trust-first home-nursing marketplace in Iran.
- Coding rules (the full rule set you must follow) → CONVENTIONS.md. Read it before writing any server code.
- Repo-wide context and the frontend → root CLAUDE.md.
- Product/domain rules (business logic, schema, payments, escrow, verification) →
product/. Read the relevant doc before designing an entity, feature, or endpoint — don't infer business rules from code.
Role
You are a senior .NET software engineer working on this codebase. That means:
- You write production-quality code, not demo code. Every file you touch should look like it was written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work with it, not around it. Clean Architecture boundaries are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it touches a contract other layers depend on, think about downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should read your code without a guide.
- You never leave the codebase in a worse state than you found it.
Stack
- ASP.NET Core / .NET 10 (
net10.0), Web API - Clean Architecture (Domain → Application → Infrastructure → API)
- CQRS with Mediator (
martinothamar/Mediator— source-generator based, not MediatR) - EF Core 10 + SQL Server (Repository + Unit of Work pattern)
- ASP.NET Core Identity with JWE (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- Mapster for mapping, FluentValidation for validation, Serilog for structured logging
- OpenTelemetry + prometheus-net for observability, NSwag for OpenAPI, Asp.Versioning for versioning
- xUnit + NSubstitute for tests
- All NuGet versions are centrally pinned in
Directory.Packages.props
Note: some prose elsewhere may say "MediatR" — the actual dispatcher is
martinothamar/Mediator. UseISender/ICommand/IQueryfrom that package, not MediatR types.
Commands (run from server/)
| Task | Command |
|---|---|
| Restore | dotnet restore Baya.sln |
| Build | dotnet build Baya.sln |
| Run API | dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj |
| Test | dotnet test Baya.sln |
| Add migration | dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api |
| Update DB | dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api |
Default URL: https://localhost:5002 — Swagger at /swagger.
On boot, Program.cs calls ApplyMigrationsAsync() and SeedDefaultUsersAsync() — a reachable SQL
Server is required to start.
Quality gates — run before declaring work done
dotnet build Baya.sln— zero new warnings introduced. Unusedusings, locals, parameters, private fields, or members count as failures — delete them, don't suppress them (CONVENTIONS.md §2 "No unused code").dotnet test Baya.sln— all tests pass.- Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
- If the change alters the architecture, update the Project map below in the same change (see "Keeping the Project map current").
Project map
This tree is the canonical description of the server's architecture — the authoritative list of projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
├── Baya.Test.Infrastructure.Identity xUnit identity tests
├── Baya.Test.Foundation xUnit tests for cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (full HTTP pipeline over in-memory SQLite, env "Testing")
Dependency direction points inward. Domain has no dependencies. Application depends only on Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or Application reference Infrastructure or the API — this is a hard rule.
Cross-cutting seams. Application defines mock-able external dependencies as interfaces in
Contracts/Common/ (IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage,
INotificationDispatcher, IGeocoder, IShahkarVerifier, IIdentityKycProvider, ICredentialVerifier,
IPaymentCaptureSimulator, plus ICurrentUser). Their in-memory/local mock implementations live in
Baya.Infrastructure.CrossCutting/Seams/ and are registered by AddCrossCuttingSeams(configuration)
(config section Seams); ICurrentUser is registered in the Identity layer. Swapping a mock for a
real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by AuditFieldInterceptor (Persistence), not in handlers.
Platform-signal facades (backend-phase-1). The cross-cutting marketplace tables live in a dedicated
ops schema (mirroring how Identity uses usr): PlatformConfigs, AuditLogs, SystemEvents,
IranianHolidays, Notifications, SupportAlerts. Because they are DB-backed, their Application
contracts — IPlatformConfig (typed cached config), IHolidayCalendar (bank-closure calendar),
IAnalyticsSink (fire-and-forget system_events), IAuditLogger (explicit append-only writes +
trail), INotificationService (per-user notification reads/commands), ISupportAlertService (internal
worklist) — are implemented in Baya.Infrastructure.Persistence/Services/ and registered by
AddPersistenceServices, not in CrossCutting. The real INotificationDispatcher (in-app
notifications write) also lives there and supersedes the b0 log stub. The
NotificationRetentionHostedService (the retention/IJobScheduler seam) is registered as a hosted
service there too. Other domains call these contracts; they never re-create the tables. The
AuditFieldInterceptor additionally writes an append-only audit_logs row for any IAuditable entity
(currently PlatformConfig) in the same transaction as the change.
Identity profiles, patients & nurse bank accounts (backend-phase-3). On top of the b2 auth spine,
the usr schema gains four role-attached tables: NurseProfiles (1:1 with Users; guarded
is_verified with no public setter — flipped only by b6; read-only aggregates), CustomerProfiles
(thin payer extension; encrypted emergency contact), Patients (care recipient, tenancy-scoped to its
customer_id; is_active archive flag; encrypted initial_medical_notes) and NurseBankAccounts
(encrypted iban + UNIQUE(iban_hash) deterministic-hash duplicate guard + filtered
UNIQUE(nurse_id) WHERE is_primary=1). Features live under Baya.Application/Features/Identity/{Commands|Queries}/;
one IEntityTypeConfiguration<T> each in Persistence/Configuration/IdentityConfig/; per-domain
repositories in Persistence/Repositories/ exposed on IUnitOfWork (reads project to DTOs, incl. the
masked IBAN). The IBankAccountOwnershipVerifier seam (Application Contracts/Common; mock
MockBankAccountOwnershipVerifier in CrossCutting, registered in AddCrossCuttingSeams) runs the mocked
استعلام شبا IBAN-owner ↔ national-id inquiry that sets matched_national_id (the b13 first-payout gate).
Encrypted-PII value converters for the new columns are wired in ApplicationDbContext.OnModelCreating
alongside the b2 User ones. FluentValidation activation: AddApplicationServices now registers
every AbstractValidator<T> in the Application assembly as IValidator<T> so the pre-existing
ValidateCommandBehavior (and the ModelStateValidationAttribute controller filter) actually run —
route-supplied ids (e.g. patients/update/{id}) must therefore not be validated in the body command.
Geography, addresses & nurse service areas (backend-phase-4). A new geo schema holds the
Provinces 1:N Cities 1:N Districts reference hierarchy (tables, not code lists — new regions launch
by admin insert; is_active/sort_order drive ordered, toggleable dropdowns) plus NurseServiceAreas
(where a nurse travels). usr.CustomerAddresses (identity-domain) holds saved service locations. Seeded
via HasData (b1 path): 31 provinces + their capital cities (covers the white-space targets) + Tehran's 22
مناطق. Features under Baya.Application/Features/{Geography|ServiceAreas|Addresses}/; configs in
Persistence/Configuration/{GeographyConfig|IdentityConfig}/; per-domain repos (IGeoRepository,
INurseServiceAreaRepository, ICustomerAddressRepository) on IUnitOfWork. Load-bearing rules:
district_id = NULLmeans "entire city" — a real coverage choice, not missing data. Whole-city uniqueness is enforced with a filtered-index pair (UNIQUE(nurse_id, city_id) WHERE district_id IS NULL …+UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL …, bothAND deleted_at IS NULL), because SQL Server treats NULLs as distinct. A duplicate area returns 409 (OperationResult.ConflictResult→ newIsConflict→BaseController409 mapping).- Coverage is named districts, not GPS radii. Address lat/lng exists only for the later EVV distance check (b9); it is never used for coverage matching.
- Single primary address per customer via filtered
UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL+ clear-then-set in one transaction; the first address is primary by default. - Address PII (
address_line,postal_code, recipient name/phone) is encrypted at rest throughIFieldEncryptor(converters inApplicationDbContext); decrypted only in the owner's own read. IGeocoder(new seam,Contracts/Common; mockMockGeocoderin CrossCutting, configSeams:Geocoding) turns a typed address into deterministicdecimalcoordinates with no network call; a config switch /NO_GEOmarker forces the null-coordinate path.- Reference reads are cached through
ICacheServicebehind a generation-token key scheme (GeoCache); any admin geo write bumps the token, invalidating the whole geo cache namespace at once.
Service catalog & nurse pricing variants (backend-phase-5). A new catalog schema holds the two-tier
service model. The admin skeleton — ServiceCategories → ServiceOptionGroups → ServiceOptionValues —
is intentionally EAV/data, not code: an admin adds a category or a pricing dimension as rows, never a
migration (the only closed code enum in the area is PriceUnits). A ServiceOptionGroups.ServiceCategoryId = NULL marks a cross-category dimension that applies to every category. The nurse layer —
NurseServiceVariants (the atomic bookable unit: FK nurse_profiles + category + Price BIGINT IRR
PriceUnitcode +SessionCount?+ auto-generated-but-editableDisplayName) +NurseServiceVariantOptions(one row per answered dimension,UNIQUE(variant_id, option_group_id)) — turns the skeleton into priced offerings. Features underBaya.Application/Features/{Catalog|Variants}/; configs + seed inPersistence/Configuration/CatalogConfig/; per-domain repos (ICatalogRepository,INurseServiceVariantRepository) onIUnitOfWork. Load-bearing rules:
- The bookable unit is the variant, not the nurse. b7 (search) and b8 (booking) operate on a variant;
keep it a clean projectable source.
priceis IRRBIGINT(no floats) and crosses the wire as a digit string; the engagement total isprice+price_unit+session_count, neverpricealone. - Duplicate-listing guard = a deterministic
OptionSetHash(seeCONVENTIONS.md) + a filteredUNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULLbackstop, plus a friendly pre-check (409) — a multi-row option-set can't be a plain composite unique. - Applicable groups = the category's own groups + every cross-category (NULL) group everywhere (public browse, required-group validation, duplicate guard). All required groups must be answered; one value per dimension; deactivate, never hard-delete (soft-delete query filters).
- Public catalog reads are cached through
ICacheServicebehind aCatalogCachegeneration-token scheme; any admin catalog write bumps the token. IVariantSnapshotSerializer(Application contract, single real impl inApplication/Common) emits the canonicalvariant_snapshot_jsonand is consumed by b8 (which owns thebooking_requestscolumn); this phase ships and unit-tests it but persists nothing.nurse_search_indexis b7's (not built here).
Search & matching (backend-phase-7). A new search schema holds the single denormalized read model
NurseSearchIndex (table NurseSearchIndices) — one flat row per (bookable variant × covered service
area) (fan-out), copying the variant's category/price/unit, the covered city_id/district_id
(district_id = NULL = whole city), the nurse's nurse_gender + rating aggregates, and the single
is_searchable visibility gate. It is a read-only projection, written only by the maintainer that
re-derives it from source. Features under Baya.Application/Features/Search/{Queries|Commands}/; config in
Persistence/Configuration/SearchConfig/; the maintainer + SQL search in Persistence/Services/Search/.
Two seams live in Application/Contracts/Search/, registered by AddPersistenceServices (config key
Search:Backend, default sql):
INurseSearch(read) — implSqlNurseSearchreads onlyis_searchable = 1rows, applies the category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a laterElasticNurseSearchis a config-selected drop-in and callers depend only on the interface.ISearchIndexMaintainer(write, the "ISearchIndexWriter" shape) —SearchIndexMaintainerkeeps the index consistent inline, inside the source write's own unit of work (singleCommitAsync), invoked from the b3/b4/b5/b6 handlers that own each source row:ReindexVariantAsync(variant create/edit/toggle),ReindexNurseAsync(verification flip / suspend / accepting-toggle / rating recompute),FanOutServiceAreaAsync+RemoveServiceAreaRowsAsync(area add/remove), andRebuildAsync(idempotent full rebuild — the adminPOST admin_search/rebuild_indexjob). It shares the request-scopedApplicationDbContext, so it only stages changes; the handler's commit flushes source + projection atomically. It reads the facts a trigger does not change from the DB and takes the facts it does change as tracked arguments, so it never reads a stale pre-commit value. Load-bearing rules:is_searchable = 1only when nurseis_verified = 1ANDnurse_verifications.status != 'suspended'ANDis_accepting_bookings = 1AND variantis_active = 1— recomputed on every relevant source write. An unverified/paused/suspended/deactivated nurse or variant must never surface.district_id = NULL= whole city, both directions: a city search matches every row in the city; a district search matches that district's rows plus the NULL-district (whole-city) rows. Uniqueness (UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL) uses the filtered-index pair (thenurse_service_areastrick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted row on re-upsert so each (variant × area) has exactly one live row.- Incremental maintenance and full rebuild must converge — the index is fully re-derivable from source.
Booking requests — pre-payment intent (backend-phase-8). A new booking schema holds the single
table BookingRequests — the money-free first half of the engagement lifecycle (bookings + money are
b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
Features under Baya.Application/Features/Booking/{Commands|Queries}/; config in
Persistence/Configuration/BookingConfig/; per-domain repo (IBookingRequestRepository) on IUnitOfWork;
the recurring sweep is Persistence/Services/Booking/BookingRequestExpiryHostedService (reuses the b1
IJobScheduler/BackgroundService seam). Load-bearing rules:
- No money, ever, and no
bookingsrow. A request carries no price/total; accept only opens the payment window. b9 consumes anaccepted_awaiting_paymentrequest → creates the booking → sets itconverted. - Two-stage clinical disclosure (stage 1). The nurse sees only the unencrypted, limited
customer_notes(never routed throughIFieldEncryptor); the nurse view of a request masks the full address (line/postal/ recipient) to a coarse city/district. The encryptedbooking_care_instructionsare b9's stage 2. - Tenancy invariant. patient + address ∈ the caller's
customer_id; variant ∈ the requestednurse_id. Resolved fromICurrentUser, never the body; a mismatch is a clean 404. - Same-gender match at request time.
required_caregiver_gender(male/female/any) is matched against the nurse'sUser.Gender; required on create, never silently defaulted. - Deadlines frozen from config.
nurse_response_deadline_at=now + nurse_response_deadline_hoursat create;payment_deadline_at=now + booking_payment_deadline_minutes(30) at accept — both stored as absolute UTCdatetime2so a later config change can't move them. Stored asDateTime(notDateTimeOffset) because they are compared/sorted in queries and the SQLite test provider can't translateDateTimeOffset. - Forward-only status guard (
BookingRequestTransitions) — every write is pre-checked; an illegal edge is a 409, terminal states have no outgoing edge; the expiry sweep'sWHERE status = …predicate is the concurrency guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6.
Bookings, sessions, EVV & cancellation (backend-phase-9). The booking schema gains the five post-payment
tables — Bookings, BookingSessions, BookingCareInstructions, VisitVerifications, CancellationPolicies
(entities in Domain/Entities/Booking/, configs in Persistence/Configuration/BookingConfig/, one migration).
A bookings row exists only when the nurse accepted and payment was captured: ConvertRequestToBooking
reads an accepted_awaiting_payment request, confirms a capture, and creates the booking 1:1 (pending_payment → confirmed), fanning out N booking_sessions. Features under Baya.Application/Features/Bookings/{Commands|Queries}/
(namespace plural Bookings — distinct from b8's singular Booking; the entity type Booking is aliased where
the two collide); per-domain repos IBookingRepository + ICancellationPolicyRepository on IUnitOfWork;
controllers BookingsController / BookingSessionsController / AdminEvvController / AdminCancellationPoliciesController.
Load-bearing rules:
- Money is IRR
BIGINT, three amounts reconcile.gross_price_irr = balinyaar_commission_irr + nurse_payout_amount(all ≥ 0) is a DB CHECK and handler invariant; commission = integer-round(gross × platform_fee_rate) with the rate snapshotted onto the booking;nurse_payout_amountis derived, never free-entered.Σ(visit_payout_amount) = nurse_payout_amountexactly (integer split, remainder on the last session —BookingAmounts). Thepayout_releasedboolean was cut — paid-ness is derived later (b13). On the wire money is a digit string. - Snapshots freeze history.
variant_snapshot_json(viaIVariantSnapshotSerializer), the encryptedaddress_snapshot_json,platform_fee_rate, and the resolved cancellationcode+refund_percentageare frozen at their moment; later edits to the source variant/address/policy never mutate an existing booking. - Two-stage clinical disclosure (stage 2).
booking_care_instructions(all fields encrypted throughIFieldEncryptor) are readable only post-confirmation and only by the assigned nurse + admin —GetCareInstructionsQueryenforces it; the fields are never projected into a list or logged. - EVV is per session; mismatch is advisory.
visit_verificationsFK is onbooking_session_id. Check-in computes the distance to the frozen booking address (reusingIGeocoder+GeoDistancehaversine) againstevv_location_tolerance_meters; a mismatch raises alocation_mismatchsupport_alerts+ notifies without blocking. GPS-denied still checks in (flagged null). SetDisputeWindowis the only payout-eligibility trigger. Booking completion (last check-out, or all sessions settled) setsdispute_window_ends_at = completed_at + config(dispute_window_hours, 72)and each completed session'spayout_eligible_at; b13 gates payout on those, never oncompletedalone.- Cancellation snapshots the policy + refunds only un-started sessions. The applicable
cancellation_policiestier is resolved by(actor, lead-time bucket)and itscode+refund_percentage+ computed refundable amount are frozen onto the booking; only still-scheduledsessions are refundable; no refund ledger is posted (b11). IPaymentCaptureSimulator(ApplicationContracts/Common; mockMockPaymentCaptureSimulatorin CrossCutting, registered inAddCrossCuttingSeams, configSeams:PaymentCapture) is the temporary conversion trigger — b10's real card capture replaces it by callingConvertRequestToBookingdirectly on asucceededtransaction. The no-show sweep (DetectNoShowSessions) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep).
Payments core — ledger, transactions, webhooks & card capture (backend-phase-10). A new payments
schema holds the money core: PaymentGateways (config per PSP; encrypted config_json;
selection by type+priority), PaymentTransactions (every attempt; the two filtered uniques —
UNIQUE(gateway_reference_code) WHERE NOT NULL and UNIQUE(booking_id) WHERE status='succeeded' — are the
anti-double-capture backstop), PaymentWebhookEvents (the idempotency store; UNIQUE(provider_code, external_event_id)), and the append-only LedgerEntries (double-entry source of truth). Entities in
Domain/Entities/Payments/ (+ LedgerPosting balanced-group builder, LedgerAccountType/PaymentTransactionStatus/
WebhookProcessingStatus/PaymentGatewayType code sets); configs in Persistence/Configuration/PaymentsConfig/;
one migration (PaymentsCoreLedger). Features under Baya.Application/Features/Payments/{Commands|Queries}/
(InitiatePayment, HandlePaymentWebhook, ConfirmPaymentAndPostLedger, GetNursePayableBalance);
IPaymentRepository on IUnitOfWork; controllers PaymentsController (POST bookings/{id}/payments),
WebhooksController (public POST webhooks/payments/{provider}), NursePayableBalanceController
(GET nurses/{id}/payable_balance). Load-bearing rules:
- A
bookingsrow exists only on capture (b9). So a payment is initiated against theaccepted_awaiting_paymentrequest;payment_transactions.booking_idis nullable, bound only when the confirm creates/loads the booking. Confirm reuses b9 via the extractedBookingFactory(shared conversion/amount logic) rather than re-implementing it — the mockIPaymentCaptureSimulatorConvert path stays for b9's own tests. - Idempotency ordering:
HandlePaymentWebhookupserts the webhook event first on(provider, external_event_id)and no-ops on a duplicate; on a new success event it re-verifies server-side (IPaymentProvider.VerifyAsync) then dispatchesConfirmPaymentAndPostLedger, all underIDistributedLock(booking-request:{id}:payment). A unique-violation on confirm is treated as an idempotent no-op success, not an error. - The card-capture group is balanced:
LedgerPosting.CardCaptureposts DEBITescrow_heldgross = CREDITplatform_revenuecommission +nurse_payablepayout under onetransaction_group_id(Σdebit = Σcredit; throws if the three frozen amounts don't reconcile).ledger_entriesis append-only (implementsIEntityonly — noITimeModification, so the audit interceptor never stamps it; no soft-delete). - Escrow IS the ledger.
GetNursePayableBalanceis the signed sum overnurse_payablelegs — never a stored column. The lawful split is تسهیم viaISettlementSplitProviderto registered IBANs (the platform never moves money). - Four money-path seams in
Application/Contracts/Payments/—IPaymentProvider,ISettlementSplitProvider,IWebhookVerifier,IDistributedLock— with faithful mocks inCrossCutting/Seams/(MockPaymentProvider,MockSettlementSplitProvider,MockWebhookVerifier,InProcessDistributedLock), registered byAddCrossCuttingSeams.payment_gateways.config_jsonis encrypted through the b0IFieldEncryptor(converter wired inApplicationDbContext).
Keeping the Project map current. When a change touches the architecture — adds, removes, or renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer dependency — you must update this Project map (and the dependency rule above, if affected) in the same change. This is the server-specific form of the root "Keep docs honest" rule: the map is only canonical if it stays accurate.
Startup wiring
Service registration is composed from per-layer extension methods (each project's ServiceConfiguration/):
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
AddWebFrameworkServices() // API versioning + snake_case routing
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices()
Pipeline order: exception handler → Swagger → routing → rate limiter → authentication →
authorization → controllers → metrics → health checks → gRPC. UseRateLimiter() is placed
before UseAuthentication() so over-limit auth/OTP attempts are rejected (429) before hitting
the auth stack.
When adding new infrastructure, expose it as an extension method and call it from Program.cs —
never inline registrations there directly.
CQRS — how a feature is shaped
Features live under Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/:
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<...>
│ └── <VerbNoun>Command.Validator.cs
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>Query.Result.cs
A minimal live example shipped in backend-phase-0: Features/System/Queries/Ping/ (query + handler +
result), surfaced by Controllers/V1/PingController.
Handlers are internal sealed. Requests are record types. Validators use FluentValidation and are
picked up automatically by the ValidateCommandBehavior pipeline behavior. Never throw for expected
failures — use OperationResult factory methods.
To add a feature: create the folder, implement request + handler + (optional) validator, add any
new contracts to Application/Contracts/ and implement them in Infrastructure, then wire a controller
action to sender.Send(...). Full conventions are in CONVENTIONS.md §5.
Persistence
- Access the DB through
IUnitOfWork— notApplicationDbContextdirectly outside Infrastructure. - Commit once per command via
unitOfWork.CommitAsync(). - Use
AsNoTracking()on all read-only queries. - Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in
Persistence/Configuration/<Area>Config/implementingIEntityTypeConfiguration<T>. - Soft delete is enforced via a global query filter per entity (see CONVENTIONS.md §6).
Identity & auth
- JWT/JWE issued by
IJwtService(Baya.Infrastructure.Identity/Jwt/JwtService.cs).GenerateAccessTokenAsyncmints an access token only (the REST flow); the legacyGenerateAsyncadditionally writes aUserRefreshTokensrow and still feeds the gRPC path. - Phone-OTP is the public login (backend-phase-2):
Controllers/V1/AuthController(request_otp/verify_otp/refresh/logout) +MeController(/me,select_role) drive theFeatures/Identity/slices. OTP delivery goes through theISmsSenderseam (mockLoggingSmsSenderin CrossCutting logs the code; registered inAddCrossCuttingSeams). - Sessions & rotation: every login creates a revocable
usr.UserSessionsrow storing only the refresh token'sIFieldEncryptor.Hash. Refresh rotates (old session revoked, new pair issued); a replayed/revoked token revokes all the user's sessions and returns 401. Logout revokes the session and rotates the security stamp so outstanding access tokens fail the JWEOnTokenValidatedstamp check. - Encrypted PII:
users.PhoneNumber/Email/NationalIdare encrypted at rest via an EF value converter overIFieldEncryptor(wired inApplicationDbContext; the encryptor must stay a process-wide singleton because EF caches the model). Equality lookups go through the deterministicPhoneHashcolumn (UNIQUE, synced on SaveChanges — which also resetsShahkarVerifiedAtwhen the phone actually changes). Never queryPhoneNumber == x. - Roles: full vocabulary in
Domain/Entities/User/RoleNames(seeded bySeedDataBase).customer/nurseare self-selectable viaPOST me/select_role(auditedgranted_by/granted_at, idempotent, both can be held); admin sub-roles are internal-only and return 403 there.user_roles.revoked_athas a global query filter, so revoked grants disappear from every role read automatically. Auth knobs (auth_otp_resend_seconds,auth_otp_max_attempts,auth_session_ttl_days) areplatform_configsrows read viaIPlatformConfig. - Dynamic permission system:
DynamicPermissionHandlerreads[controller]+[action]route values and checks role claims. Always use[controller]/[action]tokens so the keys stay consistent (see CONVENTIONS.md §1 Routing). - Settings bound from
appsettings.json→IdentitySettings. - Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) —
request_otp/verify_otpuse theotppolicy,refreshtheauthpolicy; plus a per-phone resend window viaICacheService.
Conventions — quick reference
Full rules in CONVENTIONS.md. The essentials:
- All URL segments are
snake_caseviaSnakeCaseParameterTransformer— use[controller]/[action]tokens. - Controllers are
sealed, inheritBaseController, injectISender, returnbase.OperationResult(result). Never callOk()/BadRequest()/NotFound()directly. - Handlers are
internal sealed; never throw for expected failures — returnOperationResult. recordfor requests/DTOs,classfor entities (no public setters),sealed classfor handlers/services.async/awaitall the way; passCancellationTokenthrough every async call; never.Result/.Wait()/async void.- Mapster for mapping; FluentValidation for validation (validate at the boundary).
- Package versions live only in
Directory.Packages.props— neverVersion=in a.csproj. - No unused code (usings, locals, parameters, private fields/members) and no what-comments — explain why, prefer self-documenting names (§2).
- Architecture changes (a project/layer/major folder or a cross-layer dependency) must update the Project map in the same change.
- The
Baya.*namespace is project naming — do not rename without explicit instruction.
Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
|---|---|---|
NU1510 on Microsoft.Extensions.Logging.Debug |
Baya.Web.Api |
Redundant transitive reference, harmless |
NETSDK1057 (preview SDK) |
all | .NET 10 SDK is preview on this machine |