backend phase 9

This commit is contained in:
hamid
2026-07-06 19:23:44 +03:30
parent 2cfc082a04
commit 12c7e51c32
101 changed files with 11666 additions and 8 deletions
+42 -6
View File
@@ -81,15 +81,15 @@ 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), + 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); + 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)
│ ├── 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/), 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.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) + AddCrossCuttingSeams
│ ├── 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), appsettings*.json
│ ├── 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
@@ -107,7 +107,7 @@ 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`,
plus `ICurrentUser`). Their in-memory/local mock implementations live in
`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
@@ -251,6 +251,42 @@ the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedS
409, terminal states have no outgoing edge; the expiry sweep's `WHERE 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_amount` is derived, never free-entered. `Σ(visit_payout_amount)
= nurse_payout_amount` exactly (integer split, remainder on the last session — `BookingAmounts`). The
`payout_released` boolean was **cut** — paid-ness is derived later (b13). On the wire money is a **digit string**.
- **Snapshots freeze history.** `variant_snapshot_json` (via `IVariantSnapshotSerializer`), the **encrypted**
`address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` + `refund_percentage` are 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** through
`IFieldEncryptor`) are readable **only post-confirmation** and **only** by the **assigned nurse + admin** —
`GetCareInstructionsQuery` enforces it; the fields are never projected into a list or logged.
- **EVV is per session; mismatch is advisory.** `visit_verifications` FK is on `booking_session_id`. Check-in computes
the distance to the frozen booking address (reusing `IGeocoder` + `GeoDistance` haversine) against
`evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` `support_alerts` + notifies **without
blocking**. GPS-denied still checks in (flagged null).
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Booking completion (last check-out, or all sessions
settled) sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
`payout_eligible_at`; b13 gates payout on those, never on `completed` alone.
- **Cancellation snapshots the policy + refunds only un-started sessions.** The applicable `cancellation_policies` tier
is resolved by `(actor, lead-time bucket)` and its `code` + `refund_percentage` + computed refundable amount are
frozen onto the booking; only still-`scheduled` sessions are refundable; **no refund ledger is posted (b11)**.
- **`IPaymentCaptureSimulator`** (Application `Contracts/Common`; mock `MockPaymentCaptureSimulator` in CrossCutting,
registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`) is the **temporary conversion trigger** — b10's
real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show
sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep).
**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
@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
using Baya.Application.Features.Bookings.Queries.ListCancellationPolicies;
using Baya.Application.Models.Booking;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin management of the cancellation/refund tiers. Editing a policy never mutates an already-snapshotted
/// cancellation — past cancellations froze the code + percentage onto the booking.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin cancellation-policy management (upsert + list)")]
public sealed class AdminCancellationPoliciesController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<CancellationPolicyDto>]
public async Task<IActionResult> Upsert(UpsertCancellationPolicyCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<CancellationPolicyDto>>]
public async Task<IActionResult> List(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListCancellationPoliciesQuery(), cancellationToken));
}
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
using Baya.Application.Features.Bookings.Queries.ListAdminEvv;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The admin EVV-review surface: the mismatch/no-show queue and the manual no-show sweep trigger (the same
/// idempotent command the deferred cron will call).
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin EVV review queue (location mismatch / no-show) + manual no-show sweep")]
public sealed class AdminEvvController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<AdminEvvItemDto>>]
public async Task<IActionResult> List([FromQuery] ListAdminEvvQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<NoShowSweepResult>]
public async Task<IActionResult> DetectNoShows(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken));
}
@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bookings.Commands.CancelSession;
using Baya.Application.Features.Bookings.Commands.CheckInVisit;
using Baya.Application.Features.Bookings.Commands.CheckOutVisit;
using Baya.Application.Features.Bookings.Queries.GetVisitVerification;
using Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The per-visit surface: the assigned nurse's EVV check-in/out, their "today" session list, per-session EVV
/// detail (raw GPS gated to the owning nurse + admin), and a single-session cancel. Tenancy is enforced in
/// the handlers via <c>ICurrentUser</c>.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "Booking sessions & EVV (check-in/out, today's visits, EVV detail, session cancel)")]
public sealed class BookingSessionsController(ISender sender) : BaseController
{
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<VisitVerificationDto>]
public async Task<IActionResult> CheckIn(long id, CheckInVisitCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { SessionId = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<VisitVerificationDto>]
public async Task<IActionResult> CheckOut(long id, CheckOutVisitCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { SessionId = id }, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<BookingSessionListItemDto>>]
public async Task<IActionResult> Today([FromQuery] ListSessionsForNurseQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<VisitVerificationDto>]
public async Task<IActionResult> Evv(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetVisitVerificationQuery(id), cancellationToken));
[HttpPost("[action]/{id}")]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[ProducesOkApiResponseType<CancellationResultDto>]
public async Task<IActionResult> Cancel(long id, CancelSessionCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { SessionId = id }, cancellationToken));
}
@@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bookings.Commands.CancelBooking;
using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
using Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
using Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
using Baya.Application.Features.Bookings.Queries.GetBookingDetail;
using Baya.Application.Features.Bookings.Queries.GetCareInstructions;
using Baya.Application.Features.Bookings.Queries.ListBookings;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The confirmed-booking surface: the (mock) payment-capture conversion, role-scoped detail + list, the
/// admin/dispute status transition, and the stage-2 care-instructions submit (customer/admin) + gated read
/// (assigned nurse/admin, post-confirmation). Tenancy is enforced in the handlers; money is IRR digit-strings.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "Confirmed bookings (convert/detail/list, transition, care instructions)")]
public sealed class BookingsController(ISender sender) : BaseController
{
// The mock/test conversion path — b10's real card capture calls ConvertRequestToBooking directly.
[HttpPost("[action]")]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[ProducesOkApiResponseType<BookingDetailDto>]
public async Task<IActionResult> Convert(ConvertRequestToBookingCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<BookingDetailDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBookingDetailQuery(id), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<BookingListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListBookingsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
// Admin/dispute moves only (enforced in the handler); cancellation goes through Cancel below.
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<BookingDetailDto>]
public async Task<IActionResult> Transition(long id, TransitionBookingStatusCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[ProducesOkApiResponseType<CancellationResultDto>]
public async Task<IActionResult> Cancel(long id, CancelBookingCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<CareInstructionsDto>]
public async Task<IActionResult> SubmitCareInstructions(long id, SubmitCareInstructionsCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
// Gated stage-2 read — assigned nurse + admin only, post-confirmation.
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<CareInstructionsDto>]
public async Task<IActionResult> CareInstructions(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCareInstructionsQuery(id), cancellationToken));
}
@@ -0,0 +1,26 @@
namespace Baya.Application.Common;
/// <summary>
/// Great-circle distance between two lat/lng points, in metres (haversine). Used by the EVV check-in
/// address-match: the nurse's captured GPS is compared against the booking address coordinates, and the
/// result is <b>advisory only</b> — a mismatch flags admin review, never blocks the visit.
/// </summary>
public static class GeoDistance
{
private const double EarthRadiusMeters = 6_371_000d;
public static double HaversineMeters(double lat1, double lng1, double lat2, double lng2)
{
var dLat = ToRadians(lat2 - lat1);
var dLng = ToRadians(lng2 - lng1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
+ Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2))
* Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return EarthRadiusMeters * c;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180d;
}
@@ -0,0 +1,21 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Temporary seam standing in for b10's real card capture, so <c>ConvertRequestToBookingCommand</c> is
/// testable now. <see cref="ConfirmCaptureAsync"/> returns a deterministic <i>succeeded</i> capture (a fake
/// gateway reference + optional PSP fee); a config switch can force a <i>failed</i> capture so the
/// "capture failed → no booking" path is testable. <b>This is the temporary conversion trigger, not a
/// parallel money path:</b> in b10 the real capture calls <c>ConvertRequestToBooking</c> directly after a
/// real <c>payment_transactions.succeeded</c> and this seam is removed.
/// </summary>
public interface IPaymentCaptureSimulator
{
ValueTask<PaymentCaptureResult> ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default);
}
/// <summary>The outcome of a (mock) payment capture. On failure, <see cref="GatewayReference"/> is empty.</summary>
/// <param name="Succeeded">Whether the capture succeeded — conversion runs only when true.</param>
/// <param name="GatewayReference">The gateway's capture reference (fake in the mock).</param>
/// <param name="PspFeeAmount">The gateway cost on this payment (IRR), for true margin. Null when unknown.</param>
public sealed record PaymentCaptureResult(bool Succeeded, string GatewayReference, long? PspFeeAmount);
@@ -0,0 +1,56 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The post-payment <c>bookings</c> aggregate (bookings + sessions + care instructions + EVV). Writes load
/// tracked rows; reads project to role-scoped DTOs. Money is IRR <c>long</c> throughout; the encrypted
/// address snapshot and care fields decrypt only on the gated read paths. Tenancy is enforced in the
/// handlers from the ids these projections carry — a cross-party access is a clean not-found, never a leak.
/// </summary>
public interface IBookingRepository
{
// ---- conversion ----
Task AddAsync(Booking booking, CancellationToken cancellationToken);
/// <summary>The booking id already created from this request, if any — the idempotency check that makes
/// a replayed conversion return the existing booking instead of creating a second one.</summary>
Task<long?> GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
// ---- detail + lists ----
Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
Task<PagedResult<BookingListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken);
Task<PagedResult<BookingListItemDto>> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken);
Task<PagedResult<BookingListItemDto>> ListAllAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Both participant user ids for a booking — the notification recipients. Null when absent.</summary>
Task<BookingParticipants?> GetParticipantsAsync(long bookingId, CancellationToken cancellationToken);
// ---- tracked loads for writes ----
/// <summary>Tracked booking with its sessions loaded — for the transition guard and whole-booking cancel.</summary>
Task<Booking?> GetTrackedWithSessionsAsync(long id, CancellationToken cancellationToken);
/// <summary>Tracked booking with its 1:1 care instructions loaded — for the care-instructions upsert.</summary>
Task<Booking?> GetTrackedWithCareAsync(long id, CancellationToken cancellationToken);
/// <summary>The tracked booking that owns <paramref name="sessionId"/>, with all its sessions and their
/// EVV records loaded, so a check-in/out/cancel can mutate the target session, create/complete its EVV,
/// and evaluate the sibling sessions for booking completion. Null if the session is absent.</summary>
Task<Booking?> GetTrackedBookingBySessionAsync(long sessionId, CancellationToken cancellationToken);
// ---- care instructions (gated read) ----
Task<CareInstructionsGate?> GetCareInstructionsGateAsync(long bookingId, CancellationToken cancellationToken);
// ---- sessions + EVV ----
Task<PagedResult<BookingSessionListItemDto>> ListSessionsForNurseAsync(long nurseId, DateOnly? date, int page, int pageSize, CancellationToken cancellationToken);
Task<EvvGate?> GetEvvForSessionAsync(long sessionId, CancellationToken cancellationToken);
Task<PagedResult<AdminEvvItemDto>> ListAdminEvvAsync(string type, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Tracked scheduled sessions on or before <paramref name="today"/> with their booking loaded —
/// the no-show sweep filters by the exact start-plus-threshold instant in memory (a DateOnly+TimeOnly
/// combine is not translatable), then marks the overdue ones missed.</summary>
Task<IReadOnlyList<BookingSession>> GetNoShowCandidatesAsync(DateOnly today, int batchSize, CancellationToken cancellationToken);
}
@@ -39,4 +39,14 @@ public interface IBookingRequestRepository
/// <summary>Role-agnostic detail projection (carries both party ids for authorization + the full address
/// for the customer/admin path). NULL when absent.</summary>
Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
/// <summary>Tracked lookup by id with <b>no</b> tenancy scope — the b9 conversion runs from a payment
/// capture (a system/admin path), and flips the request to <c>converted</c> in the same unit of work.
/// NULL when absent.</summary>
Task<BookingRequest?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>Everything b9 needs to convert an <c>accepted_awaiting_payment</c> request into a booking in
/// one projected read: ids + participant user ids, the engagement schedule, and the source data for the
/// two frozen snapshots (variant + decrypted address). NULL when absent.</summary>
Task<BookingConversionSource?> GetConversionSourceAsync(long id, CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// Admin-managed cancellation/refund tiers. The resolver picks the active tier for an
/// <c>(applies_to, lead-time bucket)</c> at cancel time; its <c>code</c> + <c>refund_percentage</c> are then
/// frozen onto the booking (a later edit here never mutates a past cancellation).
/// </summary>
public interface ICancellationPolicyRepository
{
/// <summary>The active tiers for an actor, ordered so the resolver can pick the first covering bucket.</summary>
Task<IReadOnlyList<CancellationPolicy>> GetActiveForActorAsync(string appliesTo, CancellationToken cancellationToken);
Task<IReadOnlyList<CancellationPolicyDto>> ListAsync(CancellationToken cancellationToken);
/// <summary>Tracked lookup by unique <c>code</c> for the admin upsert; null when the code is new.</summary>
Task<CancellationPolicy?> GetTrackedByCodeAsync(string code, CancellationToken cancellationToken);
Task AddAsync(CancellationPolicy policy, CancellationToken cancellationToken);
}
@@ -16,6 +16,8 @@ public interface IUnitOfWork
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public IBookingRequestRepository BookingRequestRepository { get; }
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,66 @@
#nullable enable
using System.Globalization;
using Baya.Application.Models.Booking;
namespace Baya.Application.Features.Bookings;
/// <summary>
/// Maps the role-agnostic booking projection to the wire DTO. Money crosses as strings of IRR-Rial digits.
/// <paramref name="includeAddress"/> is the disclosure switch: the owning customer (and admin) receive the
/// decrypted address snapshot; the nurse view omits it (a booking's coarse location suffices for the nurse,
/// the full address is surfaced only through EVV/session flows). Care-instruction clinical fields are never
/// part of this DTO.
/// </summary>
internal static class BookingMapper
{
public static BookingDetailDto ToDetailDto(BookingDetailProjection p, bool includeAddress)
=> new(
p.Id,
p.BookingRequestId,
p.Status,
p.NurseId,
p.NurseName,
p.PatientId,
p.PatientName,
p.VariantId,
p.VariantSnapshotJson,
p.CustomerAddressId,
includeAddress ? p.AddressSnapshotJson : null,
Money(p.GrossPriceIrr),
Money(p.BalinyaarCommissionIrr),
p.PlatformFeeRate,
Money(p.NursePayoutAmount),
p.PspFeeAmount is { } psp ? Money(psp) : null,
p.SessionCount,
p.ScheduledDate,
p.ScheduledTimeStart,
p.ScheduledTimeEnd,
p.ConfirmedAt,
p.CompletedAt,
p.CancelledAt,
p.CancelledBy,
p.CancellationReason,
p.CancellationPolicyCode,
p.CancellationRefundPercentage,
p.RefundableAmountIrr is { } refund ? Money(refund) : null,
p.DisputeWindowEndsAt,
p.CreatedAt,
p.Sessions.Select(ToSessionSummary).ToList());
private static BookingSessionSummaryDto ToSessionSummary(BookingSessionProjection s)
=> new(
s.Id,
s.SessionIndex,
s.ScheduledDate,
s.ScheduledTimeStart,
s.ScheduledTimeEnd,
s.Status,
Money(s.VisitPayoutAmount),
s.PayoutEligibleAt,
s.EvvStatus ?? Domain.Entities.Booking.VisitVerificationStatus.Pending,
s.CheckInAt,
s.CheckOutAt,
s.CheckInAddressMatch);
private static string Money(long irr) => irr.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,10 @@
using Baya.Domain.Entities.User;
namespace Baya.Application.Features.Bookings;
/// <summary>The admin role set that may read any booking / EVV detail and drive admin-only transitions.</summary>
internal static class BookingRoles
{
public static readonly string[] Admin =
[RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation];
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Features.Bookings;
/// <summary>
/// Shared cancellation resolution + refund arithmetic for the whole-booking and single-session cancel paths.
/// The applicable policy is resolved by <c>(actor, lead-time bucket)</c>; only un-started (still
/// <c>scheduled</c>) sessions are refundable; the refund is a percentage of those sessions' share of gross.
/// </summary>
internal static class CancellationHelper
{
public static string ResolveActor(bool isAdmin, bool isAssignedNurse)
=> isAdmin ? CancellationActor.Admin
: isAssignedNurse ? CancellationActor.Nurse
: CancellationActor.Customer;
/// <summary>Hours from <paramref name="nowUtc"/> to the scheduled start (negative once it has started).</summary>
public static double HoursBeforeStart(DateOnly date, TimeOnly start, DateTime nowUtc)
=> (date.ToDateTime(start, DateTimeKind.Utc) - nowUtc).TotalHours;
public static CancellationPolicy ResolvePolicy(IReadOnlyList<CancellationPolicy> policies, double hoursBeforeStart)
=> policies.FirstOrDefault(p => p.Covers(hoursBeforeStart));
/// <summary>The refundable amount (IRR) = <paramref name="refundPercentage"/> of the un-started sessions'
/// share of gross, rounded half away from zero.</summary>
public static long Refundable(long refundableGrossBase, decimal refundPercentage)
=> (long)decimal.Round(refundableGrossBase * refundPercentage / 100m, MidpointRounding.AwayFromZero);
}
@@ -0,0 +1,69 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelBooking;
internal sealed class CancelBookingCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CancelBookingCommand, OperationResult<CancellationResultDto>>
{
public async ValueTask<OperationResult<CancellationResultDto>> Handle(CancelBookingCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CancellationResultDto>.UnauthorizedResult("Not authenticated.");
var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<CancellationResultDto>.NotFoundResult("Booking not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isOwningCustomer = customerId == booking.CustomerId;
var isAssignedNurse = nurseId == booking.NurseId;
if (!isAdmin && !isOwningCustomer && !isAssignedNurse)
return OperationResult<CancellationResultDto>.NotFoundResult("Booking not found.");
if (!booking.CanTransitionTo(BookingStatus.Cancelled))
return OperationResult<CancellationResultDto>.ConflictResult($"A booking in '{booking.Status}' can no longer be cancelled.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var actor = CancellationHelper.ResolveActor(isAdmin, isAssignedNurse && !isOwningCustomer);
var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(actor, cancellationToken);
var hoursBefore = CancellationHelper.HoursBeforeStart(booking.ScheduledDate, booking.ScheduledTimeStart, now);
var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore);
if (policy is null)
return OperationResult<CancellationResultDto>.FailureResult("No cancellation policy applies to this booking.");
// Only un-started (still scheduled) sessions are refundable; started/completed ones are not.
var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount);
long refundableBase = 0;
foreach (var session in booking.Sessions)
{
if (session.Status != BookingSessionStatus.Scheduled)
continue;
refundableBase += grossShares[session.SessionIndex - 1];
session.TransitionTo(BookingSessionStatus.Cancelled);
}
var refundable = CancellationHelper.Refundable(refundableBase, policy.RefundPercentage);
booking.TransitionTo(BookingStatus.Cancelled, now, actor, request.Reason);
booking.RecordCancellationSnapshot(policy.Code, policy.RefundPercentage, refundable);
await unitOfWork.CommitAsync();
return OperationResult<CancellationResultDto>.SuccessResult(new CancellationResultDto(
booking.Id, null, booking.Status, policy.Code, policy.RefundPercentage, refundable.ToString()));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.CancelBooking;
public sealed class CancelBookingCommandValidator : AbstractValidator<CancelBookingCommand>
{
public CancelBookingCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelBooking;
/// <summary>Cancels a whole booking: resolves the applicable cancellation policy by lead time + actor,
/// freezes its <c>code</c> + <c>refund_percentage</c> onto the booking, marks every un-started session
/// cancelled, and moves the booking to <c>cancelled</c>. Only un-started (still <c>scheduled</c>) sessions are
/// refundable. No refund ledger is posted — that is b11. The booking id comes from the route.</summary>
public record CancelBookingCommand(string Reason, long BookingId = 0)
: IRequest<OperationResult<CancellationResultDto>>;
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelSession;
internal sealed class CancelSessionCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CancelSessionCommand, OperationResult<CancellationResultDto>>
{
public async ValueTask<OperationResult<CancellationResultDto>> Handle(CancelSessionCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CancellationResultDto>.UnauthorizedResult("Not authenticated.");
var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken);
var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId);
if (booking is null || session is null)
return OperationResult<CancellationResultDto>.NotFoundResult("Session not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isOwningCustomer = customerId == booking.CustomerId;
var isAssignedNurse = nurseId == booking.NurseId;
if (!isAdmin && !isOwningCustomer && !isAssignedNurse)
return OperationResult<CancellationResultDto>.NotFoundResult("Session not found.");
// Only an un-started session can be cancelled (and refunded); a started/finished visit cannot.
if (session.Status != BookingSessionStatus.Scheduled)
return OperationResult<CancellationResultDto>.ConflictResult("Only an un-started session can be cancelled.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var actor = CancellationHelper.ResolveActor(isAdmin, isAssignedNurse && !isOwningCustomer);
var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(actor, cancellationToken);
var hoursBefore = CancellationHelper.HoursBeforeStart(session.ScheduledDate, session.ScheduledTimeStart, now);
var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore);
if (policy is null)
return OperationResult<CancellationResultDto>.FailureResult("No cancellation policy applies to this session.");
var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount);
var refundableBase = grossShares[session.SessionIndex - 1];
var refundable = CancellationHelper.Refundable(refundableBase, policy.RefundPercentage);
session.TransitionTo(BookingSessionStatus.Cancelled);
// The booking keeps the most recent cancellation snapshot; the typed per-event record lands in b11.
booking.RecordCancellationSnapshot(policy.Code, policy.RefundPercentage, refundable);
await unitOfWork.CommitAsync();
return OperationResult<CancellationResultDto>.SuccessResult(new CancellationResultDto(
booking.Id, session.Id, booking.Status, policy.Code, policy.RefundPercentage, refundable.ToString()));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.CancelSession;
public sealed class CancelSessionCommandValidator : AbstractValidator<CancelSessionCommand>
{
public CancelSessionCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelSession;
/// <summary>Cancels a single un-started session mid-engagement: resolves + snapshots the applicable policy,
/// computes the refundable amount for that session's share of gross, and marks the session <c>cancelled</c>.
/// A session already <c>in_progress</c>/<c>completed</c> is not refundable. No refund ledger is posted (b11).
/// The session id comes from the route.</summary>
public record CancelSessionCommand(string Reason, long SessionId = 0)
: IRequest<OperationResult<CancellationResultDto>>;
@@ -0,0 +1,154 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckInVisit;
internal sealed class CheckInVisitCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
IGeocoder geocoder,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications)
: IRequestHandler<CheckInVisitCommand, OperationResult<VisitVerificationDto>>
{
private static readonly JsonSerializerOptions SnapshotJson = new() { PropertyNameCaseInsensitive = true };
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(CheckInVisitCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VisitVerificationDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VisitVerificationDto>.ForbiddenResult("Only a nurse can check in.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<VisitVerificationDto>.ForbiddenResult("No nurse profile exists yet.");
var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken);
var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId);
if (booking is null || session is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (booking.NurseId != nid)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (booking.Status is not (BookingStatus.Confirmed or BookingStatus.InProgress))
return OperationResult<VisitVerificationDto>.ConflictResult("This booking is not in a state where a visit can start.");
if (!session.CanTransitionTo(BookingSessionStatus.InProgress))
return OperationResult<VisitVerificationDto>.ConflictResult("This session cannot be checked in.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var verification = session.Verification ?? new VisitVerification { BookingSessionId = session.Id };
session.Verification = verification;
verification.CheckInAt = now;
verification.CheckInLat = request.Latitude;
verification.CheckInLng = request.Longitude;
// Advisory address match — computed only when GPS is present. GPS-denied still checks in (flagged null).
bool mismatch = false;
if (request.Latitude is { } lat && request.Longitude is { } lng)
{
var addressPoint = await ResolveAddressPointAsync(booking.AddressSnapshotJson, cancellationToken);
if (addressPoint is { } point)
{
var toleranceMeters = await platformConfig.GetConfig<int>("evv_location_tolerance_meters", cancellationToken);
var distance = GeoDistance.HaversineMeters((double)point.Lat, (double)point.Lng, (double)lat, (double)lng);
verification.CheckInDistanceMeters = (decimal)Math.Round(distance, 2);
verification.CheckInAddressMatch = distance <= toleranceMeters;
mismatch = distance > toleranceMeters;
}
}
verification.MarkCheckedIn();
session.TransitionTo(BookingSessionStatus.InProgress);
// First relevant check-in moves the booking to in_progress; subsequent check-ins leave it as-is.
if (booking.CanTransitionTo(BookingStatus.InProgress))
booking.TransitionTo(BookingStatus.InProgress, now);
await unitOfWork.CommitAsync();
// A mismatch is advisory: raise an admin alert + notify the family — never block, never cancel.
if (mismatch)
{
await supportAlerts.RaiseAsync(
SupportAlertType.EvvLocationMismatch,
entityType: "booking_session",
entityId: session.Id.ToString(),
severity: SupportAlertSeverity.Medium,
bookingId: booking.Id,
cancellationToken: cancellationToken);
var participants = await unitOfWork.BookingRepository.GetParticipantsAsync(booking.Id, cancellationToken);
if (participants is not null)
await notifications.DispatchAsync(
new Notification(
participants.CustomerUserId,
"evv_location_mismatch",
"Check-in location flagged",
"The nurse checked in outside the expected area. Our team will review it — the visit continues normally.",
JsonSerializer.Serialize(new { booking_id = booking.Id, session_id = session.Id })),
cancellationToken);
}
var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(session.Id, cancellationToken);
return OperationResult<VisitVerificationDto>.SuccessResult(gate!.Evv!);
}
// The booking address is frozen in the (decrypted) snapshot; reuse IGeocoder to resolve its coordinates,
// falling back to the coordinates already frozen into the snapshot. Returns null when neither is available.
private async Task<(decimal Lat, decimal Lng)?> ResolveAddressPointAsync(string addressSnapshotJson, CancellationToken cancellationToken)
{
AddressSnapshotJsonModel? snapshot;
try
{
snapshot = JsonSerializer.Deserialize<AddressSnapshotJsonModel>(addressSnapshotJson, SnapshotJson);
}
catch (JsonException)
{
snapshot = null;
}
if (snapshot is null)
return null;
var geo = await geocoder.GeocodeAsync(
snapshot.AddressLine ?? string.Empty,
snapshot.CityNameEn ?? string.Empty,
snapshot.DistrictNameEn,
cancellationToken);
if (geo.Latitude is { } gLat && geo.Longitude is { } gLng)
return (gLat, gLng);
if (snapshot.Latitude is { } sLat && snapshot.Longitude is { } sLng)
return (sLat, sLng);
return null;
}
private sealed record AddressSnapshotJsonModel(
string? AddressLine,
string? CityNameEn,
string? DistrictNameEn,
decimal? Latitude,
decimal? Longitude);
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckInVisit;
/// <summary>The assigned nurse clocks in: captures GPS + timestamp into the session's EVV record, computes
/// the advisory address-match against <c>evv_location_tolerance_meters</c>, and moves the session (and the
/// booking) to <c>in_progress</c>. A location mismatch raises an admin alert + notifies — it never blocks.
/// GPS-denied (null coordinates) still checks in, flagged. The session id comes from the route.</summary>
public record CheckInVisitCommand(decimal? Latitude, decimal? Longitude, long SessionId = 0)
: IRequest<OperationResult<VisitVerificationDto>>;
@@ -0,0 +1,77 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckOutVisit;
internal sealed class CheckOutVisitCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CheckOutVisitCommand, OperationResult<VisitVerificationDto>>
{
// A session is "settled" for booking-completion purposes when it can no longer become in_progress.
private static readonly string[] TerminalSessionStatuses =
[BookingSessionStatus.Completed, BookingSessionStatus.Missed, BookingSessionStatus.Cancelled];
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(CheckOutVisitCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VisitVerificationDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VisitVerificationDto>.ForbiddenResult("Only a nurse can check out.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<VisitVerificationDto>.ForbiddenResult("No nurse profile exists yet.");
var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken);
var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId);
if (booking is null || session is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (booking.NurseId != nid)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
var verification = session.Verification;
if (verification is null || verification.Status != VisitVerificationStatus.CheckedIn)
return OperationResult<VisitVerificationDto>.FailureResult("Check-out must follow an open check-in.");
if (!session.CanTransitionTo(BookingSessionStatus.Completed))
return OperationResult<VisitVerificationDto>.ConflictResult("This session cannot be checked out.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var disputeWindowHours = await platformConfig.GetConfig<int>("dispute_window_hours", cancellationToken);
verification.CheckOutAt = now;
verification.CheckOutLat = request.Latitude;
verification.CheckOutLng = request.Longitude;
verification.MarkCompleted();
session.TransitionTo(BookingSessionStatus.Completed);
// Per-session payout gate — the ONLY thing that makes this session payout-eligible (b13). Never the
// completed status alone.
session.SetPayoutEligible(now.AddHours(disputeWindowHours));
// When every session is settled, the booking completes and its dispute window opens.
var allSettled = booking.Sessions.All(s => TerminalSessionStatuses.Contains(s.Status));
if (allSettled && booking.CanTransitionTo(BookingStatus.Completed))
{
booking.TransitionTo(BookingStatus.Completed, now);
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
}
await unitOfWork.CommitAsync();
var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(session.Id, cancellationToken);
return OperationResult<VisitVerificationDto>.SuccessResult(gate!.Evv!);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckOutVisit;
/// <summary>The assigned nurse clocks out — must follow an open check-in. Captures GPS + timestamp, completes
/// the session's EVV, sets the session's per-session payout-eligibility window, and — when every session of
/// the booking is terminal — completes the booking and sets its dispute window. The session id comes from the
/// route.</summary>
public record CheckOutVisitCommand(decimal? Latitude, decimal? Longitude, long SessionId = 0)
: IRequest<OperationResult<VisitVerificationDto>>;
@@ -0,0 +1,164 @@
#nullable enable
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
// The singular b8 namespace Baya.Application.Features.Booking shadows the entity type name `Booking` when
// referenced unqualified from this (plural) Features.Bookings area — alias it to disambiguate.
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
internal sealed class ConvertRequestToBookingCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
IPaymentCaptureSimulator paymentCapture,
IVariantSnapshotSerializer variantSnapshotSerializer,
INotificationDispatcher notifications)
: IRequestHandler<ConvertRequestToBookingCommand, OperationResult<BookingDetailDto>>
{
public async ValueTask<OperationResult<BookingDetailDto>> Handle(ConvertRequestToBookingCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<BookingDetailDto>.UnauthorizedResult("Not authenticated.");
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(request.BookingRequestId, cancellationToken);
if (source is null)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking request not found.");
// The payer (owning customer) or an admin may trigger the (mock) capture. Any other caller must not
// even learn the request exists.
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var callerCustomerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (!isAdmin && callerCustomerId != source.CustomerId)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking request not found.");
// Idempotency: the UNIQUE booking_request_id means a replay can't create a second booking — return
// the one already created.
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(source.RequestId, cancellationToken);
if (existingId is { } existing)
{
var existingDetail = await unitOfWork.BookingRepository.GetDetailAsync(existing, cancellationToken);
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(existingDetail!, includeAddress: true));
}
if (source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return OperationResult<BookingDetailDto>.ConflictResult("This request is not awaiting payment and cannot be converted.");
// A booking exists ONLY on a successful capture — a failed capture creates nothing.
var capture = await paymentCapture.ConfirmCaptureAsync(source.RequestId, cancellationToken);
if (!capture.Succeeded)
return OperationResult<BookingDetailDto>.FailureResult("Payment capture failed; no booking was created.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
// session_count comes from the variant (a single visit is 1); gross = price × sessions.
var sessionCount = source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1;
var gross = source.VariantSnapshot.Price * sessionCount;
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var (commission, payout) = BookingAmounts.Split(gross, rate);
var booking = new BookingEntity
{
BookingRequestId = source.RequestId,
CustomerId = source.CustomerId,
NurseId = source.NurseId,
PatientId = source.PatientId,
VariantId = source.VariantId,
CustomerAddressId = source.CustomerAddressId,
VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot),
AddressSnapshotJson = SerializeAddress(source.AddressSnapshot),
GrossPriceIrr = gross,
BalinyaarCommissionIrr = commission,
PlatformFeeRate = rate,
NursePayoutAmount = payout,
PspFeeAmount = capture.PspFeeAmount,
SessionCount = (short)sessionCount,
ScheduledDate = source.RequestedDate,
ScheduledTimeStart = source.RequestedTimeStart,
ScheduledTimeEnd = source.RequestedTimeEnd
};
// Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount.
var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount);
for (var i = 0; i < sessionCount; i++)
{
booking.Sessions.Add(new BookingSession
{
SessionIndex = i + 1,
ScheduledDate = source.RequestedDate,
ScheduledTimeStart = source.RequestedTimeStart,
ScheduledTimeEnd = source.RequestedTimeEnd,
VisitPayoutAmount = visitPayouts[i]
});
}
booking.TransitionTo(BookingStatus.Confirmed, now);
// Flip the request → converted in the same unit of work. Re-check the tracked state so a racing
// cancel/expiry that already moved it is a clean conflict, not a double conversion.
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
return OperationResult<BookingDetailDto>.ConflictResult("This request can no longer be converted.");
trackedRequest.MarkConverted();
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
await unitOfWork.CommitAsync();
await notifications.DispatchAsync(
new Notification(
source.CustomerUserId,
"booking_confirmed",
"Booking confirmed",
"Your payment was captured and your booking is confirmed.",
JsonSerializer.Serialize(new { booking_id = booking.Id })),
cancellationToken);
await notifications.DispatchAsync(
new Notification(
source.NurseUserId,
"booking_confirmed_nurse",
"New confirmed booking",
"A booking has been confirmed and paid. The care instructions and schedule are now available.",
JsonSerializer.Serialize(new { booking_id = booking.Id })),
cancellationToken);
var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken);
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true));
}
// Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant
// snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe.
private static readonly JsonSerializerOptions AddressJson = new()
{
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};
private static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new
{
addressId = a.AddressId,
title = a.Title,
cityId = a.CityId,
cityNameFa = a.CityNameFa,
cityNameEn = a.CityNameEn,
districtId = a.DistrictId,
districtNameFa = a.DistrictNameFa,
districtNameEn = a.DistrictNameEn,
addressLine = a.AddressLine,
postalCode = a.PostalCode,
recipientName = a.RecipientName,
recipientPhone = a.RecipientPhone,
latitude = a.Latitude,
longitude = a.Longitude
}, AddressJson);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
public sealed class ConvertRequestToBookingCommandValidator : AbstractValidator<ConvertRequestToBookingCommand>
{
public ConvertRequestToBookingCommandValidator()
{
RuleFor(x => x.BookingRequestId).GreaterThan(0);
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
/// <summary>
/// The conversion engine — <b>invoked by payment capture</b> (mocked via <c>IPaymentCaptureSimulator</c>
/// now, real card capture in b10). It loads an <c>accepted_awaiting_payment</c> booking request, verifies a
/// successful capture, then creates the 1:1 <c>bookings</c> row (<c>pending_payment → confirmed</c>), writes
/// the variant + encrypted address snapshots, computes the three amounts, generates ≥ 1 reconciling session,
/// and flips the request to <c>converted</c> — all in one unit of work. Idempotent: the unique
/// <c>booking_request_id</c> means a replay returns the existing booking rather than creating a second one.
/// </summary>
public record ConvertRequestToBookingCommand(long BookingRequestId) : IRequest<OperationResult<BookingDetailDto>>;
@@ -0,0 +1,76 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.SupportAlerts;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
internal sealed class DetectNoShowSessionsCommandHandler(
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications)
: IRequestHandler<DetectNoShowSessionsCommand, OperationResult<NoShowSweepResult>>
{
private const int BatchSize = 200;
public async ValueTask<OperationResult<NoShowSweepResult>> Handle(DetectNoShowSessionsCommand request, CancellationToken cancellationToken)
{
var now = dateTimeProvider.UtcNow.UtcDateTime;
var thresholdMinutes = await platformConfig.GetConfig<int>("no_show_threshold_minutes", cancellationToken);
var today = DateOnly.FromDateTime(now);
var candidates = await unitOfWork.BookingRepository.GetNoShowCandidatesAsync(today, BatchSize, cancellationToken);
var missed = new List<BookingSession>();
foreach (var session in candidates)
{
var start = session.ScheduledDate.ToDateTime(session.ScheduledTimeStart, DateTimeKind.Utc);
if (start.AddMinutes(thresholdMinutes) > now)
continue;
if (!session.CanTransitionTo(BookingSessionStatus.Missed))
continue;
session.TransitionTo(BookingSessionStatus.Missed);
missed.Add(session);
}
if (missed.Count == 0)
return OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(0));
await unitOfWork.CommitAsync();
foreach (var session in missed)
{
await supportAlerts.RaiseAsync(
SupportAlertType.EvvNoShow,
entityType: "booking_session",
entityId: session.Id.ToString(),
severity: SupportAlertSeverity.High,
bookingId: session.BookingId,
cancellationToken: cancellationToken);
var participants = await unitOfWork.BookingRepository.GetParticipantsAsync(session.BookingId, cancellationToken);
if (participants is not null)
await notifications.DispatchAsync(
new Notification(
participants.CustomerUserId,
"evv_no_show",
"Nurse did not check in",
"The nurse did not check in for a scheduled visit. Our team has been alerted.",
JsonSerializer.Serialize(new { booking_id = session.BookingId, session_id = session.Id })),
cancellationToken);
}
return OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(missed.Count));
}
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
/// <summary>The no-show sweep unit of work: any session still <c>scheduled</c> past
/// <c>scheduled_start + no_show_threshold_minutes</c> with no check-in is marked <c>missed</c>, a
/// <c>no_show</c> support alert is raised, and the family is notified. Bounded + idempotent. The recurring
/// scheduler is DEFERRED — this is the command the cron will call, reachable now via an admin/test trigger.</summary>
public record DetectNoShowSessionsCommand : IRequest<OperationResult<NoShowSweepResult>>;
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
internal sealed class SubmitCareInstructionsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<SubmitCareInstructionsCommand, OperationResult<CareInstructionsDto>>
{
private static readonly string[] WritableStatuses =
[
BookingStatus.Confirmed, BookingStatus.InProgress,
BookingStatus.Completed, BookingStatus.Disputed, BookingStatus.Closed
];
public async ValueTask<OperationResult<CareInstructionsDto>> Handle(SubmitCareInstructionsCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CareInstructionsDto>.UnauthorizedResult("Not authenticated.");
var booking = await unitOfWork.BookingRepository.GetTrackedWithCareAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<CareInstructionsDto>.NotFoundResult("Booking not found.");
// Author = the owning customer, or an admin acting on their behalf.
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (!isAdmin && customerId != booking.CustomerId)
return OperationResult<CareInstructionsDto>.NotFoundResult("Booking not found.");
if (!WritableStatuses.Contains(booking.Status))
return OperationResult<CareInstructionsDto>.ConflictResult("Care instructions can only be added once the booking is confirmed.");
var care = booking.CareInstructions;
if (care is null)
{
care = new BookingCareInstruction { BookingId = booking.Id };
booking.CareInstructions = care;
}
care.CurrentConditions = request.CurrentConditions;
care.Medications = request.Medications;
care.Allergies = request.Allergies;
care.SpecialInstructions = request.SpecialInstructions;
care.EmergencyContactName = request.EmergencyContactName;
care.EmergencyContactPhone = request.EmergencyContactPhone;
await unitOfWork.CommitAsync();
return OperationResult<CareInstructionsDto>.SuccessResult(new CareInstructionsDto(
booking.Id,
care.CurrentConditions,
care.Medications,
care.Allergies,
care.SpecialInstructions,
care.EmergencyContactName,
care.EmergencyContactPhone));
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
public sealed class SubmitCareInstructionsCommandValidator : AbstractValidator<SubmitCareInstructionsCommand>
{
public SubmitCareInstructionsCommandValidator()
{
RuleFor(x => x.CurrentConditions).MaximumLength(2000);
RuleFor(x => x.Medications).MaximumLength(2000);
RuleFor(x => x.Allergies).MaximumLength(2000);
RuleFor(x => x.SpecialInstructions).MaximumLength(2000);
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
RuleFor(x => x.EmergencyContactPhone).MaximumLength(30);
// The booking id is route-supplied, so it is not validated in the body.
}
}
@@ -0,0 +1,17 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
/// <summary>Writes/updates the 1:1 encrypted <c>booking_care_instructions</c> for a <b>confirmed</b> booking.
/// Customer-authored (or admin). The booking id comes from the route, never the body.</summary>
public record SubmitCareInstructionsCommand(
string? CurrentConditions,
string? Medications,
string? Allergies,
string? SpecialInstructions,
string? EmergencyContactName,
string? EmergencyContactPhone,
long BookingId = 0) : IRequest<OperationResult<CareInstructionsDto>>;
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
internal sealed class TransitionBookingStatusCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<TransitionBookingStatusCommand, OperationResult<BookingDetailDto>>
{
public async ValueTask<OperationResult<BookingDetailDto>> Handle(TransitionBookingStatusCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } _)
return OperationResult<BookingDetailDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Any(BookingRoles.Admin.Contains) != true)
return OperationResult<BookingDetailDto>.ForbiddenResult("Only an admin can drive an explicit booking transition.");
var target = request.TargetStatus;
if (target == BookingStatus.Cancelled)
return OperationResult<BookingDetailDto>.FailureResult("Use the cancel endpoint so the cancellation policy is resolved and snapshotted.");
var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking not found.");
if (!booking.CanTransitionTo(target))
return OperationResult<BookingDetailDto>.ConflictResult($"A booking in '{booking.Status}' cannot transition to '{target}'.");
// No transition may contradict EVV/session state.
if (target == BookingStatus.InProgress &&
!booking.Sessions.Any(s => s.Status == BookingSessionStatus.InProgress))
return OperationResult<BookingDetailDto>.ConflictResult("Cannot move to in_progress with no session checked in.");
if (target == BookingStatus.Completed &&
booking.Sessions.Any(s => s.Status is BookingSessionStatus.Scheduled or BookingSessionStatus.InProgress))
return OperationResult<BookingDetailDto>.ConflictResult("Cannot complete a booking while a session is still scheduled or in progress.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
booking.TransitionTo(target, now, reason: request.Reason);
// An admin-driven completion still opens the dispute window (the single payout-eligibility trigger).
if (target == BookingStatus.Completed)
{
var disputeWindowHours = await platformConfig.GetConfig<int>("dispute_window_hours", cancellationToken);
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
}
await unitOfWork.CommitAsync();
var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken);
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
public sealed class TransitionBookingStatusCommandValidator : AbstractValidator<TransitionBookingStatusCommand>
{
public TransitionBookingStatusCommandValidator()
{
RuleFor(x => x.TargetStatus).NotEmpty();
RuleFor(x => x.Reason).MaximumLength(500);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
/// <summary>Applies an admin/dispute status change to a booking — only if allowed by the transition table
/// <b>and</b> consistent with EVV/session state (e.g. you cannot move to <c>in_progress</c> with no session
/// checked in, nor to <c>completed</c> while a session is still live). Cancellation goes through the cancel
/// endpoint (which snapshots the policy), not here. The booking id comes from the route.</summary>
public record TransitionBookingStatusCommand(string TargetStatus, string? Reason = null, long BookingId = 0)
: IRequest<OperationResult<BookingDetailDto>>;
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
// Admin-only access is enforced by the controller policy.
internal sealed class UpsertCancellationPolicyCommandHandler(IUnitOfWork unitOfWork)
: IRequestHandler<UpsertCancellationPolicyCommand, OperationResult<CancellationPolicyDto>>
{
public async ValueTask<OperationResult<CancellationPolicyDto>> Handle(UpsertCancellationPolicyCommand request, CancellationToken cancellationToken)
{
var policy = await unitOfWork.CancellationPolicyRepository.GetTrackedByCodeAsync(request.Code, cancellationToken);
if (policy is null)
{
policy = new CancellationPolicy { Code = request.Code };
await unitOfWork.CancellationPolicyRepository.AddAsync(policy, cancellationToken);
}
policy.AppliesTo = request.AppliesTo;
policy.HoursBeforeStartMin = request.HoursBeforeStartMin;
policy.HoursBeforeStartMax = request.HoursBeforeStartMax;
policy.RefundPercentage = request.RefundPercentage;
policy.FeeAmountIrr = request.FeeAmountIrr;
policy.FeeRate = request.FeeRate;
policy.IsActive = request.IsActive;
await unitOfWork.CommitAsync();
return OperationResult<CancellationPolicyDto>.SuccessResult(new CancellationPolicyDto(
policy.Id, policy.Code, policy.AppliesTo, policy.HoursBeforeStartMin, policy.HoursBeforeStartMax,
policy.RefundPercentage, policy.FeeAmountIrr.ToString(), policy.FeeRate, policy.IsActive));
}
}
@@ -0,0 +1,25 @@
using Baya.Domain.Entities.Booking;
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
public sealed class UpsertCancellationPolicyCommandValidator : AbstractValidator<UpsertCancellationPolicyCommand>
{
public UpsertCancellationPolicyCommandValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.AppliesTo)
.NotEmpty()
.Must(CancellationActor.IsValid)
.WithMessage("applies_to must be one of: customer, nurse, admin.");
RuleFor(x => x.RefundPercentage).InclusiveBetween(0m, 100m);
RuleFor(x => x.FeeAmountIrr).GreaterThanOrEqualTo(0);
RuleFor(x => x.FeeRate).InclusiveBetween(0m, 1m).When(x => x.FeeRate.HasValue);
RuleFor(x => x)
.Must(x => x.HoursBeforeStartMin is null || x.HoursBeforeStartMax is null || x.HoursBeforeStartMin < x.HoursBeforeStartMax)
.WithMessage("hours_before_start_min must be less than hours_before_start_max.");
}
}
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
/// <summary>Admin create/update of a cancellation tier, keyed by unique <c>code</c>. Editing a policy never
/// mutates an already-snapshotted cancellation (past cancellations froze the code + percentage). The fee is
/// a flat IRR amount plus an optional fraction; a tier uses whichever is non-zero.</summary>
public record UpsertCancellationPolicyCommand(
string Code,
string AppliesTo,
int? HoursBeforeStartMin,
int? HoursBeforeStartMax,
decimal RefundPercentage,
long FeeAmountIrr,
decimal? FeeRate,
bool IsActive) : IRequest<OperationResult<CancellationPolicyDto>>;
@@ -0,0 +1,38 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetBookingDetail;
internal sealed class GetBookingDetailQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetBookingDetailQuery, OperationResult<BookingDetailDto>>
{
public async ValueTask<OperationResult<BookingDetailDto>> Handle(GetBookingDetailQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<BookingDetailDto>.UnauthorizedResult("Not authenticated.");
var detail = await unitOfWork.BookingRepository.GetDetailAsync(request.Id, cancellationToken);
if (detail is null)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId == detail.CustomerId)
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: true));
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId == detail.NurseId)
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: false));
if (isAdmin)
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: true));
// Neither party nor admin — do not leak that the booking exists.
return OperationResult<BookingDetailDto>.NotFoundResult("Booking not found.");
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetBookingDetail;
/// <summary>Booking header + money summary + sessions + status timeline, tenancy-scoped: the customer sees
/// their own bookings, the nurse their assigned bookings, admin all. Never cross-tenant; the nurse view omits
/// the address snapshot and no query ever surfaces care-instruction clinical fields.</summary>
public record GetBookingDetailQuery(long Id) : IRequest<OperationResult<BookingDetailDto>>;
@@ -0,0 +1,45 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetCareInstructions;
internal sealed class GetCareInstructionsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetCareInstructionsQuery, OperationResult<CareInstructionsDto>>
{
// The clinical fields are visible only once the booking is confirmed and while it is a live/closed
// engagement — never pre-payment, never on a cancelled booking.
private static readonly string[] DisclosableStatuses =
[
BookingStatus.Confirmed, BookingStatus.InProgress,
BookingStatus.Completed, BookingStatus.Disputed, BookingStatus.Closed
];
public async ValueTask<OperationResult<CareInstructionsDto>> Handle(GetCareInstructionsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CareInstructionsDto>.UnauthorizedResult("Not authenticated.");
var gate = await unitOfWork.BookingRepository.GetCareInstructionsGateAsync(request.BookingId, cancellationToken);
if (gate is null)
return OperationResult<CareInstructionsDto>.NotFoundResult("Booking not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isAssignedNurse = nurseId == gate.NurseId;
// Two-stage disclosure: only the assigned nurse or an admin, only post-confirmation. Any other caller
// (the customer, an unassigned nurse, or a pre-confirmation booking) must not learn anything.
if ((!isAssignedNurse && !isAdmin) || !DisclosableStatuses.Contains(gate.BookingStatus))
return OperationResult<CareInstructionsDto>.NotFoundResult("Care instructions not found.");
if (gate.Instructions is null)
return OperationResult<CareInstructionsDto>.NotFoundResult("No care instructions have been added for this booking yet.");
return OperationResult<CareInstructionsDto>.SuccessResult(gate.Instructions);
}
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetCareInstructions;
/// <summary>The <b>gated</b> stage-2 clinical read. Decrypts and returns the care instructions <b>only</b> to
/// (a) the assigned nurse of that booking and (b) admin, and <b>only</b> post-confirmation. Any other caller
/// — the customer, an unassigned nurse, or a pre-confirmation booking — gets a clean not-found. This is the
/// two-stage disclosure boundary; it must never leak.</summary>
public record GetCareInstructionsQuery(long BookingId) : IRequest<OperationResult<CareInstructionsDto>>;
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetVisitVerification;
internal sealed class GetVisitVerificationQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetVisitVerificationQuery, OperationResult<VisitVerificationDto>>
{
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(GetVisitVerificationQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VisitVerificationDto>.UnauthorizedResult("Not authenticated.");
var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(request.SessionId, cancellationToken);
if (gate is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
// Raw GPS detail is gated to the owning nurse + admin only.
if (nurseId != gate.NurseId && !isAdmin)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (gate.Evv is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("No EVV record exists for this session yet.");
return OperationResult<VisitVerificationDto>.SuccessResult(gate.Evv);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetVisitVerification;
/// <summary>Per-session EVV detail. Raw GPS is gated to the owning nurse + admin only; any other caller gets
/// a clean not-found.</summary>
public record GetVisitVerificationQuery(long SessionId) : IRequest<OperationResult<VisitVerificationDto>>;
@@ -0,0 +1,22 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListAdminEvv;
// Admin-only access is enforced by the controller policy; this handler assumes an admin caller.
internal sealed class ListAdminEvvQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListAdminEvvQuery, OperationResult<PagedResult<AdminEvvItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<AdminEvvItemDto>>> Handle(ListAdminEvvQuery request, CancellationToken cancellationToken)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var type = request.Type == "no_show" ? "no_show" : "mismatch";
var result = await unitOfWork.BookingRepository.ListAdminEvvAsync(type, page, pageSize, cancellationToken);
return OperationResult<PagedResult<AdminEvvItemDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListAdminEvv;
/// <summary>The admin EVV-review queue: <c>type=mismatch</c> (advisory location mismatches) or
/// <c>type=no_show</c> (missed sessions). Projected + paginated; behind the admin policy.</summary>
public record ListAdminEvvQuery(string Type = "mismatch", int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<AdminEvvItemDto>>>;
@@ -0,0 +1,52 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListBookings;
internal sealed class ListBookingsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListBookingsQuery, OperationResult<PagedResult<BookingListItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<BookingListItemDto>>> Handle(ListBookingsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<BookingListItemDto>>.UnauthorizedResult("Not authenticated.");
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var role = request.Role?.ToLowerInvariant();
if (role == "all")
{
if (currentUser.Roles?.Any(BookingRoles.Admin.Contains) != true)
return OperationResult<PagedResult<BookingListItemDto>>.ForbiddenResult("Only an admin can list all bookings.");
var all = await unitOfWork.BookingRepository.ListAllAsync(request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(all);
}
if (role == "nurse")
{
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(Empty(page, pageSize));
var nurseList = await unitOfWork.BookingRepository.ListForNurseAsync(nid, request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(nurseList);
}
// Default: the customer's own bookings.
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(Empty(page, pageSize));
var list = await unitOfWork.BookingRepository.ListForCustomerAsync(cid, request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(list);
}
private static PagedResult<BookingListItemDto> Empty(int page, int pageSize)
=> new([], 0, page, pageSize);
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListBookings;
/// <summary>The role-scoped "My bookings" list. <c>role=customer</c> (default) or <c>role=nurse</c> scopes
/// to the caller; <c>role=all</c> is the admin-only list-everything variant. Status-filterable, projected,
/// paginated.</summary>
public record ListBookingsQuery(string? Role = null, string? Status = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<BookingListItemDto>>>;
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListCancellationPolicies;
// Admin-only access is enforced by the controller policy.
internal sealed class ListCancellationPoliciesQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListCancellationPoliciesQuery, OperationResult<IReadOnlyList<CancellationPolicyDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<CancellationPolicyDto>>> Handle(ListCancellationPoliciesQuery request, CancellationToken cancellationToken)
{
var policies = await unitOfWork.CancellationPolicyRepository.ListAsync(cancellationToken);
return OperationResult<IReadOnlyList<CancellationPolicyDto>>.SuccessResult(policies);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListCancellationPolicies;
/// <summary>All cancellation tiers (active + inactive) for the admin management screen.</summary>
public record ListCancellationPoliciesQuery : IRequest<OperationResult<IReadOnlyList<CancellationPolicyDto>>>;
@@ -0,0 +1,32 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListSessionsForNurseQuery, OperationResult<PagedResult<BookingSessionListItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<BookingSessionListItemDto>>> Handle(ListSessionsForNurseQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<BookingSessionListItemDto>>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<PagedResult<BookingSessionListItemDto>>.ForbiddenResult("Only a nurse can view their sessions.");
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(new PagedResult<BookingSessionListItemDto>([], 0, page, pageSize));
var result = await unitOfWork.BookingRepository.ListSessionsForNurseAsync(nid, request.Date, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
/// <summary>The signed-in nurse's sessions for a day (today's visits by default), each with its check-in/out
/// CTA state. Tenancy-scoped to the nurse via <c>ICurrentUser</c>, projected + paginated.</summary>
public record ListSessionsForNurseQuery(DateOnly? Date = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<BookingSessionListItemDto>>>;
@@ -0,0 +1,142 @@
#nullable enable
namespace Baya.Application.Models.Booking;
/// <summary>
/// The full booking view returned by convert/detail/transition/cancel. Money crosses the wire as strings of
/// IRR-Rial digits (integer money, no floats). The <b>nurse</b> view omits the encrypted address snapshot;
/// the <b>customer/admin</b> view includes it. Care-instruction clinical fields are <b>never</b> here — they
/// live behind the gated care-instructions read.
/// </summary>
public record BookingDetailDto(
long Id,
long BookingRequestId,
string Status,
long NurseId,
string NurseName,
long PatientId,
string PatientName,
long VariantId,
string VariantSnapshotJson,
long CustomerAddressId,
string? AddressSnapshotJson,
string GrossPriceIrr,
string BalinyaarCommissionIrr,
decimal PlatformFeeRate,
string NursePayoutAmount,
string? PspFeeAmount,
short SessionCount,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
DateTime? ConfirmedAt,
DateTime? CompletedAt,
DateTime? CancelledAt,
string? CancelledBy,
string? CancellationReason,
string? CancellationPolicyCode,
decimal? CancellationRefundPercentage,
string? RefundableAmountIrr,
DateTime? DisputeWindowEndsAt,
DateTimeOffset CreatedAt,
IReadOnlyList<BookingSessionSummaryDto> Sessions);
/// <summary>Per-visit summary embedded in the booking detail — schedule, status, payout, and EVV state.</summary>
public record BookingSessionSummaryDto(
long Id,
int SessionIndex,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
string Status,
string VisitPayoutAmount,
DateTime? PayoutEligibleAt,
string EvvStatus,
DateTime? CheckInAt,
DateTime? CheckOutAt,
bool? CheckInAddressMatch);
/// <summary>The role-scoped "My bookings" list item — counterparty is the nurse (customer view) or the
/// patient (nurse view); <see cref="AmountIrr"/> is the gross (customer) or the payout (nurse).</summary>
public record BookingListItemDto(
long Id,
string Status,
string CounterpartyName,
DateOnly ScheduledDate,
short SessionCount,
string AmountIrr,
DateTime? DisputeWindowEndsAt,
DateTimeOffset CreatedAt);
/// <summary>The nurse's "today" session-list item, with the per-session check-in/out CTA state.</summary>
public record BookingSessionListItemDto(
long SessionId,
long BookingId,
int SessionIndex,
string PatientName,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
string Status,
string EvvStatus);
/// <summary>The decrypted stage-2 clinical fields — returned <b>only</b> to the assigned nurse + admin,
/// <b>only</b> post-confirmation. Never projected into a list or logged.</summary>
public record CareInstructionsDto(
long BookingId,
string? CurrentConditions,
string? Medications,
string? Allergies,
string? SpecialInstructions,
string? EmergencyContactName,
string? EmergencyContactPhone);
/// <summary>Per-session EVV detail — raw GPS gated to the owning nurse + admin.</summary>
public record VisitVerificationDto(
long Id,
long BookingSessionId,
string Status,
DateTime? CheckInAt,
decimal? CheckInLat,
decimal? CheckInLng,
DateTime? CheckOutAt,
decimal? CheckOutLat,
decimal? CheckOutLng,
bool? CheckInAddressMatch,
decimal? CheckInDistanceMeters);
/// <summary>An item in the admin EVV-review queue (location mismatch or no-show).</summary>
public record AdminEvvItemDto(
long SessionId,
long BookingId,
long NurseId,
string SessionStatus,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
DateTime? CheckInAt,
bool? CheckInAddressMatch,
decimal? CheckInDistanceMeters);
/// <summary>Admin-facing cancellation-policy row.</summary>
public record CancellationPolicyDto(
long Id,
string Code,
string AppliesTo,
int? HoursBeforeStartMin,
int? HoursBeforeStartMax,
decimal RefundPercentage,
string FeeAmountIrr,
decimal? FeeRate,
bool IsActive);
/// <summary>The outcome of a cancellation — the frozen policy snapshot + computed refundable amount. No
/// refund ledger is posted here (that is b11); this is the figure b11 will consume.</summary>
public record CancellationResultDto(
long BookingId,
long? SessionId,
string BookingStatus,
string PolicyCode,
decimal RefundPercentage,
string RefundableAmountIrr);
/// <summary>How many sessions the no-show sweep flagged (idempotent — re-running with none returns zero).</summary>
public record NoShowSweepResult(int Missed);
@@ -0,0 +1,111 @@
#nullable enable
using Baya.Application.Models.Catalog;
namespace Baya.Application.Models.Booking;
/// <summary>
/// The role-agnostic booking detail projection. Carries both parties' ids so the query handler can authorize
/// the caller and decide address-snapshot visibility, plus the decrypted address snapshot (masked for the
/// nurse). Money is the raw IRR <c>long</c>; the mapper stringifies it for the wire.
/// </summary>
public record BookingDetailProjection(
long Id,
long BookingRequestId,
string Status,
long CustomerId,
long NurseId,
string NurseName,
long PatientId,
string PatientName,
long VariantId,
string VariantSnapshotJson,
long CustomerAddressId,
string AddressSnapshotJson,
long GrossPriceIrr,
long BalinyaarCommissionIrr,
decimal PlatformFeeRate,
long NursePayoutAmount,
long? PspFeeAmount,
short SessionCount,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
DateTime? ConfirmedAt,
DateTime? CompletedAt,
DateTime? CancelledAt,
string? CancelledBy,
string? CancellationReason,
string? CancellationPolicyCode,
decimal? CancellationRefundPercentage,
long? RefundableAmountIrr,
DateTime? DisputeWindowEndsAt,
DateTimeOffset CreatedAt,
IReadOnlyList<BookingSessionProjection> Sessions);
/// <summary>Per-visit projection embedded in <see cref="BookingDetailProjection"/>.</summary>
public record BookingSessionProjection(
long Id,
int SessionIndex,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
string Status,
long VisitPayoutAmount,
DateTime? PayoutEligibleAt,
string? EvvStatus,
DateTime? CheckInAt,
DateTime? CheckOutAt,
bool? CheckInAddressMatch);
/// <summary>
/// Everything <c>ConvertRequestToBookingCommand</c> needs to build a booking from an
/// <c>accepted_awaiting_payment</c> request in one read: the ids + participant user ids (for notifications),
/// the engagement schedule, and the source data for the two frozen snapshots.
/// </summary>
public record BookingConversionSource(
long RequestId,
string Status,
long CustomerId,
int CustomerUserId,
long NurseId,
int NurseUserId,
long PatientId,
string PatientName,
long VariantId,
long CustomerAddressId,
DateOnly RequestedDate,
TimeOnly RequestedTimeStart,
TimeOnly RequestedTimeEnd,
VariantSnapshot VariantSnapshot,
AddressSnapshot AddressSnapshot);
/// <summary>The immutable address data frozen into <c>address_snapshot_json</c> at booking time — full,
/// decrypted, plus the geocoded coordinates the EVV distance check later reads (so EVV is independent of
/// later address edits).</summary>
public record AddressSnapshot(
long AddressId,
string Title,
long CityId,
string CityNameFa,
string CityNameEn,
long? DistrictId,
string? DistrictNameFa,
string? DistrictNameEn,
string AddressLine,
string PostalCode,
string RecipientName,
string RecipientPhone,
decimal? Latitude,
decimal? Longitude);
/// <summary>The two participant user ids for a booking — the recipients of booking notifications.</summary>
public record BookingParticipants(int CustomerUserId, int NurseUserId);
/// <summary>The authorization envelope for the gated care-instructions read: the booking's status + both
/// party ids, plus the decrypted instructions (null when none written yet). The handler enforces the
/// two-stage disclosure boundary from these facts before ever surfacing <see cref="Instructions"/>.</summary>
public record CareInstructionsGate(string BookingStatus, long NurseId, long CustomerId, CareInstructionsDto? Instructions);
/// <summary>The authorization envelope for the per-session EVV read: both party ids + the EVV detail (null
/// when no verification exists yet). Raw GPS is surfaced only to the owning nurse + admin.</summary>
public record EvvGate(long NurseId, long CustomerId, VisitVerificationDto? Evv);
@@ -0,0 +1,141 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The <b>confirmed engagement</b> — the source of truth for a service event and its money split. A
/// <see cref="Booking"/> exists <b>only</b> when the nurse accepted <i>and</i> payment was captured; it is
/// created 1:1 from an <c>accepted_awaiting_payment</c> booking request and never from an accept alone.
/// <para>
/// Money is IRR <c>BIGINT</c> only. The three amounts must always reconcile —
/// <see cref="GrossPriceIrr"/> = <see cref="BalinyaarCommissionIrr"/> + <see cref="NursePayoutAmount"/>,
/// all ≥ 0 — enforced both by a DB CHECK and by the conversion handler; <see cref="NursePayoutAmount"/> is
/// derived, never free-entered. <see cref="PlatformFeeRate"/>, <see cref="VariantSnapshotJson"/> and the
/// encrypted <see cref="AddressSnapshotJson"/> are <b>frozen at conversion</b>: later edits to the source
/// variant/address/config rows must never mutate an existing booking.
/// </para>
/// <para>
/// There is deliberately <b>no</b> "payout done" boolean — paid-ness is derived later from a
/// <c>nurse_payout_booking_links</c> row + the ledger (b13). Payout eligibility is derived from
/// <see cref="DisputeWindowEndsAt"/> passing with no open dispute, never from <see cref="Status"/> alone.
/// </para>
/// </summary>
public class Booking : BaseEntity<long>
{
/// <summary>1:1 with the request that created it (UNIQUE) — the idempotency key for conversion.</summary>
public long BookingRequestId { get; set; }
// Denormalized FKs (copied from the request) for query performance.
public long CustomerId { get; set; }
public long NurseId { get; set; }
public long PatientId { get; set; }
public long VariantId { get; set; }
public long CustomerAddressId { get; set; }
/// <summary>The licensed center / merchant-of-record. <c>partner_centers</c> is DEFERRED to b15, so the
/// FK stays nullable and unset for now.</summary>
public long? PartnerCenterId { get; set; }
/// <summary>Variant + option labels frozen at booking time (never re-resolved from the live variant).</summary>
public string VariantSnapshotJson { get; set; } = null!;
/// <summary>Full address frozen at booking time — <b>encrypted at rest</b> through the field encryptor.</summary>
public string AddressSnapshotJson { get; set; } = null!;
/// <summary>Total charged the customer (IRR).</summary>
public long GrossPriceIrr { get; set; }
/// <summary>Balinyaar's own cut (IRR) = round(<see cref="GrossPriceIrr"/> × <see cref="PlatformFeeRate"/>).</summary>
public long BalinyaarCommissionIrr { get; set; }
/// <summary>The commission rate snapshot frozen at conversion, for audit.</summary>
public decimal PlatformFeeRate { get; set; }
/// <summary>Derived: <see cref="GrossPriceIrr"/> <see cref="BalinyaarCommissionIrr"/> (IRR).</summary>
public long NursePayoutAmount { get; set; }
/// <summary>Gateway cost on this payment (IRR), for true margin. Null until a capture sets it.</summary>
public long? PspFeeAmount { get; set; }
/// <summary>1 = single visit; &gt; 1 = multi-session engagement. Always ≥ 1.</summary>
public short SessionCount { get; set; } = 1;
// Engagement-level schedule; the per-visit schedule lives on booking_sessions.
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTimeStart { get; set; }
public TimeOnly ScheduledTimeEnd { get; set; }
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the
/// allowed-transition machine.</summary>
public string Status { get; private set; } = BookingStatus.PendingPayment;
public DateTime? ConfirmedAt { get; private set; }
public DateTime? CancelledAt { get; private set; }
public string? CancellationReason { get; private set; }
/// <summary>Who cancelled — a <see cref="CancellationActor"/> code.</summary>
public string? CancelledBy { get; private set; }
/// <summary>The resolved cancellation policy <c>code</c>, <b>frozen</b> at cancel time (a later policy
/// edit must not change it). Absent a b11 refunds/cancellation-event table, the snapshot lives here.</summary>
public string? CancellationPolicyCode { get; private set; }
/// <summary>The resolved <c>refund_percentage</c> (0100), frozen at cancel time.</summary>
public decimal? CancellationRefundPercentage { get; private set; }
/// <summary>The computed refundable amount (IRR) for the un-started sessions at cancel time. The refund
/// ledger/execution is b11; this is the frozen figure b11 consumes.</summary>
public long? RefundableAmountIrr { get; private set; }
public DateTime? CompletedAt { get; private set; }
/// <summary>Set on completion = <see cref="CompletedAt"/> + config(<c>dispute_window_hours</c>, 72). The
/// <b>only</b> thing that makes a payout eligible (b13) — never <see cref="Status"/> = completed alone.</summary>
public DateTime? DisputeWindowEndsAt { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<BookingSession> Sessions { get; set; } = new List<BookingSession>();
public BookingCareInstruction? CareInstructions { get; set; }
public bool CanTransitionTo(string target) => BookingTransitions.CanTransition(Status, target);
/// <summary>
/// Applies a status change through the allowed-transition guard and stamps the matching lifecycle
/// timestamp. Callers pre-check with <see cref="CanTransitionTo"/> and return a clean conflict; reaching
/// an illegal edge here is a programming error, so it fails fast rather than overwriting a terminal state.
/// </summary>
public void TransitionTo(string target, DateTime now, string? actor = null, string? reason = null)
{
if (!BookingTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal booking transition {Status} → {target}.");
Status = target;
switch (target)
{
case BookingStatus.Confirmed:
ConfirmedAt = now;
break;
case BookingStatus.Completed:
CompletedAt = now;
break;
case BookingStatus.Cancelled:
CancelledAt = now;
CancelledBy = actor;
CancellationReason = reason;
break;
}
}
/// <summary>Freezes the resolved cancellation policy snapshot + the computed refundable amount.</summary>
public void RecordCancellationSnapshot(string policyCode, decimal refundPercentage, long refundableAmountIrr)
{
CancellationPolicyCode = policyCode;
CancellationRefundPercentage = refundPercentage;
RefundableAmountIrr = refundableAmountIrr;
}
/// <summary>Sets the dispute window on completion — the single payout-eligibility trigger for the booking.</summary>
public void SetDisputeWindow(DateTime endsAt) => DisputeWindowEndsAt = endsAt;
}
@@ -0,0 +1,44 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// Pure integer-money helpers for the booking split. All money is IRR <c>long</c> — no float survives into
/// storage. Kept here (not in a handler) so the reconciliation rules are unit-testable in isolation.
/// </summary>
public static class BookingAmounts
{
/// <summary>
/// Splits <paramref name="gross"/> into the platform commission and the derived nurse payout using the
/// snapshotted <paramref name="rate"/>. Commission is integer-rounded (half away from zero) and clamped
/// to <c>[0, gross]</c> so the invariant <c>gross = commission + payout</c> with all ≥ 0 always holds.
/// </summary>
public static (long Commission, long Payout) Split(long gross, decimal rate)
{
var commission = (long)decimal.Round(gross * rate, MidpointRounding.AwayFromZero);
if (commission < 0)
commission = 0;
if (commission > gross)
commission = gross;
return (commission, gross - commission);
}
/// <summary>
/// Distributes <paramref name="payout"/> across <paramref name="sessionCount"/> sessions so the parts
/// sum <b>exactly</b> to <paramref name="payout"/> — equal integer shares with the remainder placed on
/// the last session, so no Rial is created or lost.
/// </summary>
public static long[] SplitPayout(long payout, int sessionCount)
{
if (sessionCount < 1)
throw new ArgumentOutOfRangeException(nameof(sessionCount), "A booking always has at least one session.");
var per = payout / sessionCount;
var amounts = new long[sessionCount];
for (var i = 0; i < sessionCount; i++)
amounts[i] = per;
// Remainder of the integer division lands on the last session so Σ == payout exactly.
amounts[sessionCount - 1] += payout - per * sessionCount;
return amounts;
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// Encrypted clinical/logistical context for a booking — <b>stage 2</b> of the two-stage disclosure
/// boundary. Kept in its own 1:1 table (not on <see cref="Booking"/>) so the financial/scheduling row stays
/// clean and these fields carry stricter access control: readable <b>only post-confirmation</b> and <b>only</b>
/// by the assigned nurse + admin (never the customer, never an unassigned nurse). Every field is encrypted
/// at rest through the field encryptor and is <b>never</b> projected into a list query or logged.
/// </summary>
public class BookingCareInstruction : BaseEntity<long>
{
public long BookingId { get; set; }
public Booking Booking { get; set; }
/// <summary>Encrypted at rest.</summary>
public string CurrentConditions { get; set; }
/// <summary>Encrypted at rest.</summary>
public string Medications { get; set; }
/// <summary>Encrypted at rest.</summary>
public string Allergies { get; set; }
/// <summary>Encrypted at rest.</summary>
public string SpecialInstructions { get; set; }
/// <summary>Encrypted at rest.</summary>
public string EmergencyContactName { get; set; }
/// <summary>Encrypted at rest.</summary>
public string EmergencyContactPhone { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,55 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// One <b>visit</b> within a booking — always ≥ 1 per booking, even for a single-visit engagement, so the
/// EVV/payout path is uniform. Each session is independently scheduled, EVV-verified, and payout-accrued.
/// <para>
/// The sum of every session's <see cref="VisitPayoutAmount"/> exactly equals the booking's
/// <c>nurse_payout_amount</c> (integer split, remainder on the last session — no Rial created or lost).
/// <see cref="PayoutEligibleAt"/> is set on completion and is the per-session payout gate consumed by b13.
/// </para>
/// </summary>
public class BookingSession : BaseEntity<long>
{
public long BookingId { get; set; }
public Booking Booking { get; set; } = null!;
/// <summary>1-based ordinal within the booking.</summary>
public int SessionIndex { get; set; }
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTimeStart { get; set; }
public TimeOnly ScheduledTimeEnd { get; set; }
/// <summary>This session's portion of the booking's <c>nurse_payout_amount</c> (IRR).</summary>
public long VisitPayoutAmount { get; set; }
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/>.</summary>
public string Status { get; private set; } = BookingSessionStatus.Scheduled;
/// <summary>Per-session dispute-window close, set on completion. The per-session payout gate (b13).</summary>
public DateTime? PayoutEligibleAt { get; private set; }
/// <summary>Set when this session is cancelled — references the cancellation snapshot. The typed
/// cancellation-event/refund record arrives in b11; nullable and unset here.</summary>
public long? CancellationEventId { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public VisitVerification? Verification { get; set; }
public bool CanTransitionTo(string target) => BookingSessionTransitions.CanTransition(Status, target);
public void TransitionTo(string target)
{
if (!BookingSessionTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal booking-session transition {Status} → {target}.");
Status = target;
}
public void SetPayoutEligible(DateTime at) => PayoutEligibleAt = at;
}
@@ -0,0 +1,23 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed status vocabulary of a <see cref="BookingSession"/> — one visit within a booking. Persisted
/// as stable snake_case codes. Edges live in <see cref="BookingSessionTransitions"/>.
/// </summary>
public static class BookingSessionStatus
{
/// <summary>Planned, no EVV check-in yet. The only state a cancellation refunds (un-started).</summary>
public const string Scheduled = "scheduled";
/// <summary>The nurse checked in (EVV open).</summary>
public const string InProgress = "in_progress";
/// <summary>The nurse checked out; the visit happened. Sets the session's payout-eligibility window.</summary>
public const string Completed = "completed";
/// <summary>No check-in by the no-show threshold (set by the no-show sweep). Terminal.</summary>
public const string Missed = "missed";
/// <summary>The session was cancelled before it started. Terminal.</summary>
public const string Cancelled = "cancelled";
}
@@ -0,0 +1,31 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The allowed status machine for <see cref="BookingSession"/>. A scheduled visit can start (check-in),
/// be cancelled, or be marked missed (no-show sweep); an in-progress visit can complete (check-out) or be
/// cancelled. Completed/missed/cancelled are terminal.
/// </summary>
public static class BookingSessionTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[BookingSessionStatus.Scheduled] =
[
BookingSessionStatus.InProgress,
BookingSessionStatus.Cancelled,
BookingSessionStatus.Missed
],
[BookingSessionStatus.InProgress] =
[
BookingSessionStatus.Completed,
BookingSessionStatus.Cancelled
],
[BookingSessionStatus.Completed] = [],
[BookingSessionStatus.Missed] = [],
[BookingSessionStatus.Cancelled] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -0,0 +1,31 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed status vocabulary of a <see cref="Booking"/> — the <b>post-payment</b> half of the
/// engagement lifecycle. Persisted as these stable snake_case codes (never a C# enum member name). The
/// allowed edges live in <see cref="BookingTransitions"/> and may never contradict EVV/session state.
/// </summary>
public static class BookingStatus
{
/// <summary>Transient — a booking is created here and immediately confirmed on captured payment.</summary>
public const string PendingPayment = "pending_payment";
/// <summary>Payment captured, sessions generated, care instructions may now be added. Bookable engagement.</summary>
public const string Confirmed = "confirmed";
/// <summary>At least one session has an open EVV check-in.</summary>
public const string InProgress = "in_progress";
/// <summary>Every session is completed/cancelled/missed; sets the dispute window.</summary>
public const string Completed = "completed";
/// <summary>A dispute was opened inside the dispute window (admin flow).</summary>
public const string Disputed = "disputed";
/// <summary>The dispute window closed / dispute resolved. Terminal — payout eligibility is derived
/// from <c>dispute_window_ends_at</c> + the ledger (b13), never from this status.</summary>
public const string Closed = "closed";
/// <summary>The engagement was cancelled by customer/nurse/admin. Terminal.</summary>
public const string Cancelled = "cancelled";
}
@@ -0,0 +1,27 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The allowed status machine for <see cref="Booking"/>. Every transition is validated here so an illegal
/// edge is a clean conflict (never a silent overwrite) and terminal states have no outgoing edge. A CHECK
/// constraint backs the terminal states in the DB; this table is the authoritative guard consulted by
/// <c>TransitionBookingStatusCommand</c> and the internal capture/check-in/check-out flows. No transition
/// may contradict EVV — that extra invariant is enforced by the handlers, not encodable here.
/// </summary>
public static class BookingTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[BookingStatus.PendingPayment] = [BookingStatus.Confirmed, BookingStatus.Cancelled],
[BookingStatus.Confirmed] = [BookingStatus.InProgress, BookingStatus.Cancelled],
[BookingStatus.InProgress] = [BookingStatus.Completed, BookingStatus.Cancelled],
[BookingStatus.Completed] = [BookingStatus.Disputed, BookingStatus.Closed],
[BookingStatus.Disputed] = [BookingStatus.Closed],
// Terminal states — no outgoing edges.
[BookingStatus.Closed] = [],
[BookingStatus.Cancelled] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -0,0 +1,15 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed code set for who initiated a cancellation — both the <c>cancellation_policies.applies_to</c>
/// dimension and the value frozen into <c>bookings.cancelled_by</c> at cancel time.
/// </summary>
public static class CancellationActor
{
public const string Customer = "customer";
public const string Nurse = "nurse";
public const string Admin = "admin";
public static bool IsValid(string value)
=> value is Customer or Nurse or Admin;
}
@@ -0,0 +1,65 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// A config-driven, snapshot-able cancellation/refund tier keyed by initiating actor + lead-time bucket.
/// The applicable row is resolved at cancel time by <c>(applies_to, hours-before-start)</c> and its
/// <see cref="Code"/> + <see cref="RefundPercentage"/> are <b>frozen onto the booking</b> — a later edit to
/// the policy row must never change a past cancellation.
/// <para>
/// The penalty/fee is modelled as a flat IRR amount (<see cref="FeeAmountIrr"/>) <b>plus</b> an optional
/// fraction (<see cref="FeeRate"/>): a tier uses whichever is non-zero/non-null. At MVP the customer tiers
/// carry neither; the nurse-no-show penalty is modelled but its posting is deferred to payouts (b13).
/// </para>
/// </summary>
public class CancellationPolicy : BaseEntity<long>
{
/// <summary>Stable unique code, e.g. <c>standard_24h</c>, <c>nurse_no_show</c>.</summary>
public string Code { get; set; } = null!;
/// <summary>Who the tier applies to — a <see cref="CancellationActor"/> code.</summary>
public string AppliesTo { get; set; } = null!;
/// <summary>Lower bound (inclusive) of the lead-time bucket in hours; null = open lower bound.</summary>
public int? HoursBeforeStartMin { get; set; }
/// <summary>Upper bound (exclusive) of the lead-time bucket in hours; null = open upper bound.</summary>
public int? HoursBeforeStartMax { get; set; }
/// <summary>Refund fraction of the un-started portion, 0100.</summary>
public decimal RefundPercentage { get; set; }
/// <summary>Flat cancellation fee / nurse penalty (IRR). 0 = none.</summary>
public long FeeAmountIrr { get; set; }
/// <summary>Optional cancellation fee / nurse penalty as a fraction. Null = none.</summary>
public decimal? FeeRate { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>True when a lead time of <paramref name="hoursBeforeStart"/> falls in this tier's half-open
/// bucket <c>[min, max)</c> (null bound = open).</summary>
public bool Covers(double hoursBeforeStart)
=> (HoursBeforeStartMin is null || hoursBeforeStart >= HoursBeforeStartMin)
&& (HoursBeforeStartMax is null || hoursBeforeStart < HoursBeforeStartMax);
}
/// <summary>Stable seed codes for the baseline cancellation tiers.</summary>
public static class CancellationPolicyCode
{
/// <summary>Customer, ≥ 24h before start → full refund.</summary>
public const string Standard24h = "standard_24h";
/// <summary>Customer, &lt; 24h before start → 50% refund.</summary>
public const string StandardInside24h = "standard_inside_24h";
/// <summary>Nurse no-show / nurse-initiated → full refund (+ nurse penalty, posted in b13).</summary>
public const string NurseNoShow = "nurse_no_show";
/// <summary>Admin-initiated → full refund, no penalty.</summary>
public const string AdminCancellation = "admin_cancellation";
}
@@ -0,0 +1,41 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The Electronic Visit Verification (EVV) record — GPS + timestamped check-in/out proving a visit happened;
/// <b>required for payout</b>. The FK is on the <see cref="BookingSessionId"/> (not the booking) so each
/// visit in a multi-session engagement is verified independently. Raw GPS detail is sensitive and gated to
/// the owning nurse + admin only. <see cref="CheckInAddressMatch"/> is <b>advisory</b>: a mismatch flags an
/// admin review alert but never blocks the visit or withholds payout on its own.
/// </summary>
public class VisitVerification : BaseEntity<long>
{
public long BookingSessionId { get; set; }
public BookingSession Session { get; set; } = null!;
public DateTime? CheckInAt { get; set; }
public decimal? CheckInLat { get; set; }
public decimal? CheckInLng { get; set; }
public DateTime? CheckOutAt { get; set; }
public decimal? CheckOutLat { get; set; }
public decimal? CheckOutLng { get; set; }
/// <summary>Advisory: did the check-in fall within <c>evv_location_tolerance_meters</c> of the booking
/// address? Null when GPS was unavailable at check-in (still allowed, flagged).</summary>
public bool? CheckInAddressMatch { get; set; }
/// <summary>The computed check-in distance to the booking address, for the admin review screen.</summary>
public decimal? CheckInDistanceMeters { get; set; }
/// <summary>Guarded — mutated only through the transition helpers.</summary>
public string Status { get; private set; } = VisitVerificationStatus.Pending;
public DateTimeOffset? DeletedAt { get; set; }
public void MarkCheckedIn() => Status = VisitVerificationStatus.CheckedIn;
public void MarkCompleted() => Status = VisitVerificationStatus.Completed;
}
@@ -0,0 +1,18 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed status vocabulary of a <see cref="VisitVerification"/> (EVV). It stays consistent with the
/// parent booking/session state via the documented mapping: <c>checked_in</c> ↔ session <c>in_progress</c>
/// ↔ booking <c>in_progress</c>; <c>completed</c> ↔ session <c>completed</c>.
/// </summary>
public static class VisitVerificationStatus
{
/// <summary>No check-in recorded yet.</summary>
public const string Pending = "pending";
/// <summary>An open check-in (GPS + timestamp captured), awaiting check-out.</summary>
public const string CheckedIn = "checked_in";
/// <summary>Both check-in and check-out recorded — the proof the visit happened.</summary>
public const string Completed = "completed";
}
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IPaymentCaptureSimulator"/> — the temporary conversion trigger until b10's
/// real card capture. It returns a <i>succeeded</i> capture with a stable fake gateway reference and the
/// configured PSP fee, so <c>ConvertRequestToBookingCommand</c> can be exercised end-to-end now. Set
/// <see cref="PaymentCaptureOptions.ForceFailure"/> to exercise the "capture failed → no booking" path.
/// The real capture replaces this registration in b10 — no mock behaviour is baked into any handler.
/// </summary>
public sealed class MockPaymentCaptureSimulator(IOptions<SeamOptions> options) : IPaymentCaptureSimulator
{
private readonly PaymentCaptureOptions _options = options.Value.PaymentCapture;
public ValueTask<PaymentCaptureResult> ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new PaymentCaptureResult(false, string.Empty, null));
return ValueTask.FromResult(new PaymentCaptureResult(
Succeeded: true,
GatewayReference: $"mock-capture-{bookingRequestId}",
PspFeeAmount: _options.PspFeeAmount));
}
}
@@ -14,6 +14,21 @@ public sealed class SeamOptions
public GeocodingOptions Geocoding { get; set; } = new();
public ShahkarOptions Shahkar { get; set; } = new();
public IdentityKycOptions IdentityKyc { get; set; } = new();
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IPaymentCaptureSimulator</c> (backend-phase-9). By default every capture succeeds with
/// a fake gateway reference and <see cref="PspFeeAmount"/>. Set <see cref="ForceFailure"/> to exercise the
/// "capture failed → no booking" path. The real b10 card capture ignores these.
/// </summary>
public sealed class PaymentCaptureOptions
{
/// <summary>When true, every capture returns a failed result so conversion refuses to create a booking.</summary>
public bool ForceFailure { get; set; }
/// <summary>The PSP/gateway fee (IRR) the mock reports on a successful capture.</summary>
public long? PspFeeAmount { get; set; }
}
/// <summary>
@@ -43,6 +43,11 @@ public static class ServiceCollectionExtension
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
// Payment-capture trigger (backend-phase-9). The mock returns a deterministic succeeded capture so
// ConvertRequestToBooking is testable now; in b10 the real card capture replaces this registration
// and calls ConvertRequestToBooking directly on a real payment_transactions.succeeded.
services.AddSingleton<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
return services;
}
}
@@ -1,6 +1,7 @@
using System.Reflection;
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
@@ -139,5 +140,22 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{
builder.Property(c => c.CredentialNumber).HasConversion(encrypted);
});
// b9 snapshot + clinical PII: the frozen address snapshot and every booking_care_instructions field
// are encrypted at rest through the same seam. The care fields are the stage-2 clinical disclosure —
// decrypted only in the gated care-instructions read (assigned nurse + admin, post-confirmation).
modelBuilder.Entity<Booking>(builder =>
{
builder.Property(b => b.AddressSnapshotJson).HasConversion(encrypted);
});
modelBuilder.Entity<BookingCareInstruction>(builder =>
{
builder.Property(c => c.CurrentConditions).HasConversion(encrypted);
builder.Property(c => c.Medications).HasConversion(encrypted);
builder.Property(c => c.Allergies).HasConversion(encrypted);
builder.Property(c => c.SpecialInstructions).HasConversion(encrypted);
builder.Property(c => c.EmergencyContactName).HasConversion(encrypted);
builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingCareInstructionConfig : IEntityTypeConfiguration<BookingCareInstruction>
{
public void Configure(EntityTypeBuilder<BookingCareInstruction> builder)
{
builder.ToTable("BookingCareInstructions", "booking");
// All clinical fields are encrypted at rest (converters wired in ApplicationDbContext) and are
// nvarchar(max); shorter contact fields still ride the same seam, so no length cap here.
// 1:1 with the booking (UNIQUE index created automatically).
builder.HasOne(c => c.Booking)
.WithOne(b => b.CareInstructions)
.HasForeignKey<BookingCareInstruction>(c => c.BookingId)
.IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,56 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingConfig : IEntityTypeConfiguration<Booking>
{
public void Configure(EntityTypeBuilder<Booking> builder)
{
// The three-amount split is a DB CHECK (not handler-only): gross = commission + payout, all ≥ 0.
builder.ToTable("Bookings", "booking", t => t.HasCheckConstraint(
"CK_Bookings_AmountSplit",
"[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] " +
"AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"));
// Snapshots freeze history. AddressSnapshotJson is encrypted at rest (converter wired in
// ApplicationDbContext); both are nvarchar(max) so no length cap.
builder.Property(b => b.VariantSnapshotJson).IsRequired();
builder.Property(b => b.AddressSnapshotJson).IsRequired();
builder.Property(b => b.PlatformFeeRate).HasPrecision(5, 4);
builder.Property(b => b.CancellationRefundPercentage).HasPrecision(5, 2);
builder.Property(b => b.Status).HasMaxLength(30).IsRequired();
builder.Property(b => b.CancellationReason).HasMaxLength(500);
builder.Property(b => b.CancelledBy).HasMaxLength(20);
builder.Property(b => b.CancellationPolicyCode).HasMaxLength(50);
builder.HasIndex(b => new { b.CustomerId, b.Status });
builder.HasIndex(b => new { b.NurseId, b.Status });
// b13 selects payout-eligible bookings through the dispute-window close.
builder.HasIndex(b => b.DisputeWindowEndsAt);
// 1:1 with the request that created it (UNIQUE index created automatically). No navigation either
// side — the request is read by id at conversion time.
builder.HasOne<BookingRequest>()
.WithOne()
.HasForeignKey<Booking>(b => b.BookingRequestId)
.IsRequired();
// Denormalized FKs for query performance (no navigations on Booking). partner_center_id is left
// FK-less and nullable — partner_centers is DEFERRED to b15.
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(b => b.CustomerId).IsRequired();
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(b => b.NurseId).IsRequired();
builder.HasOne<Patient>().WithMany().HasForeignKey(b => b.PatientId).IsRequired();
builder.HasOne<NurseServiceVariant>().WithMany().HasForeignKey(b => b.VariantId).IsRequired();
builder.HasOne<CustomerAddress>().WithMany().HasForeignKey(b => b.CustomerAddressId).IsRequired();
builder.HasMany(b => b.Sessions).WithOne(s => s.Booking).HasForeignKey(s => s.BookingId);
builder.HasQueryFilter(b => b.DeletedAt == null);
}
}
@@ -0,0 +1,26 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingSessionConfig : IEntityTypeConfiguration<BookingSession>
{
public void Configure(EntityTypeBuilder<BookingSession> builder)
{
builder.ToTable("BookingSessions", "booking");
builder.Property(s => s.Status).HasMaxLength(20).IsRequired();
// The nurse's "today" view and the no-show sweep both read (status, scheduled_date).
builder.HasIndex(s => new { s.BookingId, s.SessionIndex });
builder.HasIndex(s => new { s.Status, s.ScheduledDate });
// 1:1 with its EVV record (the FK is on the verification side, on the session).
builder.HasOne(s => s.Verification)
.WithOne(v => v.Session)
.HasForeignKey<VisitVerification>(v => v.BookingSessionId);
builder.HasQueryFilter(s => s.DeletedAt == null);
}
}
@@ -0,0 +1,59 @@
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class CancellationPolicyConfig : IEntityTypeConfiguration<CancellationPolicy>
{
public void Configure(EntityTypeBuilder<CancellationPolicy> builder)
{
builder.ToTable("CancellationPolicies", "booking");
builder.Property(p => p.Code).HasMaxLength(50).IsRequired();
builder.Property(p => p.AppliesTo).HasMaxLength(20).IsRequired();
builder.Property(p => p.RefundPercentage).HasPrecision(5, 2);
builder.Property(p => p.FeeRate).HasPrecision(5, 4);
builder.HasIndex(p => p.Code).IsUnique();
builder.HasIndex(p => new { p.AppliesTo, p.IsActive });
builder.HasQueryFilter(p => p.DeletedAt == null);
builder.HasData(Seed());
}
// Baseline tiers, seeded via HasData so they land with this phase's migration on a fresh DB. Numbers the
// product doc leaves open (the nurse penalty) default safe and are flagged in the phase report — the
// nurse-no-show penalty is modelled (FeeRate) but posting it is deferred to payouts (b13).
private static object[] Seed()
{
var ts = SeedConstants.Timestamp;
(long Id, string Code, string AppliesTo, int? Min, int? Max, decimal Refund, long Fee, decimal? Rate)[] rows =
[
(1, CancellationPolicyCode.Standard24h, CancellationActor.Customer, 24, null, 100m, 0L, null),
// Open lower bound so a cancel < 24h before start (or after it) always resolves a customer tier.
(2, CancellationPolicyCode.StandardInside24h, CancellationActor.Customer, null, 24, 50m, 0L, null),
(3, CancellationPolicyCode.NurseNoShow, CancellationActor.Nurse, null, null, 100m, 0L, 0m),
(4, CancellationPolicyCode.AdminCancellation, CancellationActor.Admin, null, null, 100m, 0L, null),
];
return rows
.Select(r => (object)new
{
r.Id,
r.Code,
r.AppliesTo,
HoursBeforeStartMin = r.Min,
HoursBeforeStartMax = r.Max,
RefundPercentage = r.Refund,
FeeAmountIrr = r.Fee,
FeeRate = r.Rate,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
}
@@ -0,0 +1,27 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class VisitVerificationConfig : IEntityTypeConfiguration<VisitVerification>
{
public void Configure(EntityTypeBuilder<VisitVerification> builder)
{
builder.ToTable("VisitVerifications", "booking");
builder.Property(v => v.Status).HasMaxLength(20).IsRequired();
builder.Property(v => v.CheckInLat).HasPrecision(9, 6);
builder.Property(v => v.CheckInLng).HasPrecision(9, 6);
builder.Property(v => v.CheckOutLat).HasPrecision(9, 6);
builder.Property(v => v.CheckOutLng).HasPrecision(9, 6);
builder.Property(v => v.CheckInDistanceMeters).HasPrecision(10, 2);
// The FK/1:1 to the session is configured on BookingSessionConfig; the UNIQUE index on
// BookingSessionId is created automatically. The admin mismatch queue reads on the advisory flag.
builder.HasIndex(v => v.CheckInAddressMatch);
builder.HasQueryFilter(v => v.DeletedAt == null);
}
}
@@ -44,6 +44,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(14, "auth_otp_max_attempts", "5", ConfigDataType.Int, "Wrong-code attempts allowed before OTP verification is refused until a fresh code."),
(15, "auth_session_ttl_days", "30", ConfigDataType.Int, "Refresh-token session lifetime in days."),
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
];
return rows
@@ -0,0 +1,379 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BookingsSessionsEvvCancellation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Bookings",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingRequestId = table.Column<long>(type: "bigint", nullable: false),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
PatientId = table.Column<long>(type: "bigint", nullable: false),
VariantId = table.Column<long>(type: "bigint", nullable: false),
CustomerAddressId = table.Column<long>(type: "bigint", nullable: false),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
VariantSnapshotJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
AddressSnapshotJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
GrossPriceIrr = table.Column<long>(type: "bigint", nullable: false),
BalinyaarCommissionIrr = table.Column<long>(type: "bigint", nullable: false),
PlatformFeeRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false),
NursePayoutAmount = table.Column<long>(type: "bigint", nullable: false),
PspFeeAmount = table.Column<long>(type: "bigint", nullable: true),
SessionCount = table.Column<short>(type: "smallint", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
ScheduledTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CancelledAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CancellationReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CancelledBy = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CancellationPolicyCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
CancellationRefundPercentage = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true),
RefundableAmountIrr = table.Column<long>(type: "bigint", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DisputeWindowEndsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Bookings", x => x.Id);
table.CheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0");
table.ForeignKey(
name: "FK_Bookings_BookingRequests_BookingRequestId",
column: x => x.BookingRequestId,
principalSchema: "booking",
principalTable: "BookingRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_CustomerAddresses_CustomerAddressId",
column: x => x.CustomerAddressId,
principalSchema: "usr",
principalTable: "CustomerAddresses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_Patients_PatientId",
column: x => x.PatientId,
principalSchema: "usr",
principalTable: "Patients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "CancellationPolicies",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
AppliesTo = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
HoursBeforeStartMin = table.Column<int>(type: "int", nullable: true),
HoursBeforeStartMax = table.Column<int>(type: "int", nullable: true),
RefundPercentage = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
FeeAmountIrr = table.Column<long>(type: "bigint", nullable: false),
FeeRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CancellationPolicies", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BookingCareInstructions",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
CurrentConditions = table.Column<string>(type: "nvarchar(max)", nullable: true),
Medications = table.Column<string>(type: "nvarchar(max)", nullable: true),
Allergies = table.Column<string>(type: "nvarchar(max)", nullable: true),
SpecialInstructions = table.Column<string>(type: "nvarchar(max)", nullable: true),
EmergencyContactName = table.Column<string>(type: "nvarchar(max)", nullable: true),
EmergencyContactPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BookingCareInstructions", x => x.Id);
table.ForeignKey(
name: "FK_BookingCareInstructions_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "BookingSessions",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
SessionIndex = table.Column<int>(type: "int", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
ScheduledTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
VisitPayoutAmount = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
PayoutEligibleAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CancellationEventId = table.Column<long>(type: "bigint", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BookingSessions", x => x.Id);
table.ForeignKey(
name: "FK_BookingSessions_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "VisitVerifications",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingSessionId = table.Column<long>(type: "bigint", nullable: false),
CheckInAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CheckInLat = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckInLng = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckOutAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CheckOutLat = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckOutLng = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckInAddressMatch = table.Column<bool>(type: "bit", nullable: true),
CheckInDistanceMeters = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VisitVerifications", x => x.Id);
table.ForeignKey(
name: "FK_VisitVerifications_BookingSessions_BookingSessionId",
column: x => x.BookingSessionId,
principalSchema: "booking",
principalTable: "BookingSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "booking",
table: "CancellationPolicies",
columns: new[] { "Id", "AppliesTo", "Code", "CreatedAt", "CreatedById", "DeletedAt", "FeeAmountIrr", "FeeRate", "HoursBeforeStartMax", "HoursBeforeStartMin", "IsActive", "ModifiedAt", "ModifiedById", "RefundPercentage" },
values: new object[,]
{
{ 1L, "customer", "standard_24h", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, null, 24, true, null, null, 100m },
{ 2L, "customer", "standard_inside_24h", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, 24, null, true, null, null, 50m },
{ 3L, "nurse", "nurse_no_show", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, 0m, null, null, true, null, null, 100m },
{ 4L, "admin", "admin_cancellation", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, null, null, true, null, null, 100m }
});
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 17L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", "no_show_threshold_minutes", null, null, "60" },
{ 18L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", "no_show_scan_cadence_hours", null, null, "1" }
});
migrationBuilder.CreateIndex(
name: "IX_BookingCareInstructions_BookingId",
schema: "booking",
table: "BookingCareInstructions",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bookings_BookingRequestId",
schema: "booking",
table: "Bookings",
column: "BookingRequestId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bookings_CustomerAddressId",
schema: "booking",
table: "Bookings",
column: "CustomerAddressId");
migrationBuilder.CreateIndex(
name: "IX_Bookings_CustomerId_Status",
schema: "booking",
table: "Bookings",
columns: new[] { "CustomerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Bookings_DisputeWindowEndsAt",
schema: "booking",
table: "Bookings",
column: "DisputeWindowEndsAt");
migrationBuilder.CreateIndex(
name: "IX_Bookings_NurseId_Status",
schema: "booking",
table: "Bookings",
columns: new[] { "NurseId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Bookings_PatientId",
schema: "booking",
table: "Bookings",
column: "PatientId");
migrationBuilder.CreateIndex(
name: "IX_Bookings_VariantId",
schema: "booking",
table: "Bookings",
column: "VariantId");
migrationBuilder.CreateIndex(
name: "IX_BookingSessions_BookingId_SessionIndex",
schema: "booking",
table: "BookingSessions",
columns: new[] { "BookingId", "SessionIndex" });
migrationBuilder.CreateIndex(
name: "IX_BookingSessions_Status_ScheduledDate",
schema: "booking",
table: "BookingSessions",
columns: new[] { "Status", "ScheduledDate" });
migrationBuilder.CreateIndex(
name: "IX_CancellationPolicies_AppliesTo_IsActive",
schema: "booking",
table: "CancellationPolicies",
columns: new[] { "AppliesTo", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_CancellationPolicies_Code",
schema: "booking",
table: "CancellationPolicies",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VisitVerifications_BookingSessionId",
schema: "booking",
table: "VisitVerifications",
column: "BookingSessionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VisitVerifications_CheckInAddressMatch",
schema: "booking",
table: "VisitVerifications",
column: "CheckInAddressMatch");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookingCareInstructions",
schema: "booking");
migrationBuilder.DropTable(
name: "CancellationPolicies",
schema: "booking");
migrationBuilder.DropTable(
name: "VisitVerifications",
schema: "booking");
migrationBuilder.DropTable(
name: "BookingSessions",
schema: "booking");
migrationBuilder.DropTable(
name: "Bookings",
schema: "booking");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 17L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 18L);
}
}
}
@@ -98,6 +98,197 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AddressSnapshotJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("BalinyaarCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("BookingRequestId")
.HasColumnType("bigint");
b.Property<string>("CancellationPolicyCode")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("CancellationReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal?>("CancellationRefundPercentage")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime2");
b.Property<string>("CancelledBy")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime2");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<long>("CustomerAddressId")
.HasColumnType("bigint");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTime?>("DisputeWindowEndsAt")
.HasColumnType("datetime2");
b.Property<long>("GrossPriceIrr")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long>("NursePayoutAmount")
.HasColumnType("bigint");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<long>("PatientId")
.HasColumnType("bigint");
b.Property<decimal>("PlatformFeeRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.Property<long?>("PspFeeAmount")
.HasColumnType("bigint");
b.Property<long?>("RefundableAmountIrr")
.HasColumnType("bigint");
b.Property<DateOnly>("ScheduledDate")
.HasColumnType("date");
b.Property<TimeOnly>("ScheduledTimeEnd")
.HasColumnType("time");
b.Property<TimeOnly>("ScheduledTimeStart")
.HasColumnType("time");
b.Property<short>("SessionCount")
.HasColumnType("smallint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.Property<string>("VariantSnapshotJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("BookingRequestId")
.IsUnique();
b.HasIndex("CustomerAddressId");
b.HasIndex("DisputeWindowEndsAt");
b.HasIndex("PatientId");
b.HasIndex("VariantId");
b.HasIndex("CustomerId", "Status");
b.HasIndex("NurseId", "Status");
b.ToTable("Bookings", "booking", t =>
{
t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Allergies")
.HasColumnType("nvarchar(max)");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("CurrentConditions")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EmergencyContactName")
.HasColumnType("nvarchar(max)");
b.Property<string>("EmergencyContactPhone")
.HasColumnType("nvarchar(max)");
b.Property<string>("Medications")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.ToTable("BookingCareInstructions", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.Property<long>("Id")
@@ -187,6 +378,245 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("BookingRequests", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<long?>("CancellationEventId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<DateTime?>("PayoutEligibleAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("ScheduledDate")
.HasColumnType("date");
b.Property<TimeOnly>("ScheduledTimeEnd")
.HasColumnType("time");
b.Property<TimeOnly>("ScheduledTimeStart")
.HasColumnType("time");
b.Property<int>("SessionIndex")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("VisitPayoutAmount")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId", "SessionIndex");
b.HasIndex("Status", "ScheduledDate");
b.ToTable("BookingSessions", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AppliesTo")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("FeeAmountIrr")
.HasColumnType("bigint");
b.Property<decimal?>("FeeRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.Property<int?>("HoursBeforeStartMax")
.HasColumnType("int");
b.Property<int?>("HoursBeforeStartMin")
.HasColumnType("int");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<decimal>("RefundPercentage")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("AppliesTo", "IsActive");
b.ToTable("CancellationPolicies", "booking");
b.HasData(
new
{
Id = 1L,
AppliesTo = "customer",
Code = "standard_24h",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
HoursBeforeStartMin = 24,
IsActive = true,
RefundPercentage = 100m
},
new
{
Id = 2L,
AppliesTo = "customer",
Code = "standard_inside_24h",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
HoursBeforeStartMax = 24,
IsActive = true,
RefundPercentage = 50m
},
new
{
Id = 3L,
AppliesTo = "nurse",
Code = "nurse_no_show",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
FeeRate = 0m,
IsActive = true,
RefundPercentage = 100m
},
new
{
Id = 4L,
AppliesTo = "admin",
Code = "admin_cancellation",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
IsActive = true,
RefundPercentage = 100m
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BookingSessionId")
.HasColumnType("bigint");
b.Property<bool?>("CheckInAddressMatch")
.HasColumnType("bit");
b.Property<DateTime?>("CheckInAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckInDistanceMeters")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<decimal?>("CheckInLat")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<decimal?>("CheckInLng")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<DateTime?>("CheckOutAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckOutLat")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<decimal?>("CheckOutLng")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("BookingSessionId")
.IsUnique();
b.HasIndex("CheckInAddressMatch");
b.ToTable("VisitVerifications", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Property<long>("Id")
@@ -707,6 +1137,24 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).",
Key = "verification_expiry_scan_cadence_hours",
Value = "24"
},
new
{
Id = 17L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.",
Key = "no_show_threshold_minutes",
Value = "60"
},
new
{
Id = 18L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).",
Key = "no_show_scan_cadence_hours",
Value = "1"
});
});
@@ -3165,6 +3613,56 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null)
.WithMany()
.HasForeignKey("CustomerAddressId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.Patient", null)
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null)
.WithMany()
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking")
.WithOne("CareInstructions")
.HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress")
@@ -3208,6 +3706,28 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking")
.WithMany("Sessions")
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session")
.WithOne("Verification")
.HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
@@ -3578,6 +4098,18 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("StepType");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Navigation("CareInstructions");
b.Navigation("Sessions");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b =>
{
b.Navigation("Verification");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Navigation("Options");
@@ -0,0 +1,272 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class BookingRepository : BaseAsyncRepository<Booking>, IBookingRepository
{
public BookingRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Booking booking, CancellationToken cancellationToken)
=> base.AddAsync(booking);
public Task<long?> GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(b => b.BookingRequestId == bookingRequestId)
.Select(b => (long?)b.Id)
.FirstOrDefaultAsync(cancellationToken);
public async Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
{
var row = await (
from b in TableNoTracking
where b.Id == id
join n in DbContext.Set<NurseProfile>() on b.NurseId equals n.Id
join p in DbContext.Set<Patient>() on b.PatientId equals p.Id
select new
{
Booking = b,
NurseName = n.User.Name,
NurseFamily = n.User.FamilyName,
PatientName = p.DisplayName,
Sessions = b.Sessions
.OrderBy(s => s.SessionIndex)
.Select(s => new BookingSessionProjection(
s.Id,
s.SessionIndex,
s.ScheduledDate,
s.ScheduledTimeStart,
s.ScheduledTimeEnd,
s.Status,
s.VisitPayoutAmount,
s.PayoutEligibleAt,
s.Verification == null ? null : s.Verification.Status,
s.Verification == null ? null : s.Verification.CheckInAt,
s.Verification == null ? null : s.Verification.CheckOutAt,
s.Verification == null ? null : s.Verification.CheckInAddressMatch))
.ToList()
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
var bk = row.Booking;
return new BookingDetailProjection(
bk.Id, bk.BookingRequestId, bk.Status, bk.CustomerId, bk.NurseId,
ComposeName(row.NurseName, row.NurseFamily), bk.PatientId, row.PatientName,
bk.VariantId, bk.VariantSnapshotJson, bk.CustomerAddressId, bk.AddressSnapshotJson,
bk.GrossPriceIrr, bk.BalinyaarCommissionIrr, bk.PlatformFeeRate, bk.NursePayoutAmount, bk.PspFeeAmount,
bk.SessionCount, bk.ScheduledDate, bk.ScheduledTimeStart, bk.ScheduledTimeEnd,
bk.ConfirmedAt, bk.CompletedAt, bk.CancelledAt, bk.CancelledBy, bk.CancellationReason,
bk.CancellationPolicyCode, bk.CancellationRefundPercentage, bk.RefundableAmountIrr,
bk.DisputeWindowEndsAt, bk.CreatedAt, row.Sessions);
}
public Task<PagedResult<BookingListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken)
=> ListAsync(TableNoTracking.Where(b => b.CustomerId == customerId), status, forNurse: false, page, pageSize, cancellationToken);
public Task<PagedResult<BookingListItemDto>> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken)
=> ListAsync(TableNoTracking.Where(b => b.NurseId == nurseId), status, forNurse: true, page, pageSize, cancellationToken);
public Task<PagedResult<BookingListItemDto>> ListAllAsync(string? status, int page, int pageSize, CancellationToken cancellationToken)
=> ListAsync(TableNoTracking, status, forNurse: false, page, pageSize, cancellationToken);
private async Task<PagedResult<BookingListItemDto>> ListAsync(
IQueryable<Booking> source, string? status, bool forNurse, int page, int pageSize, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(status))
source = source.Where(b => b.Status == status);
var total = await source.CountAsync(cancellationToken);
var rows = await (
from b in source
join n in DbContext.Set<NurseProfile>() on b.NurseId equals n.Id
join p in DbContext.Set<Patient>() on b.PatientId equals p.Id
orderby b.Id descending
select new
{
b.Id,
b.Status,
NurseName = n.User.Name,
NurseFamily = n.User.FamilyName,
PatientName = p.DisplayName,
b.ScheduledDate,
b.SessionCount,
b.GrossPriceIrr,
b.NursePayoutAmount,
b.DisputeWindowEndsAt,
b.CreatedAt
})
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new BookingListItemDto(
r.Id,
r.Status,
forNurse ? r.PatientName : ComposeName(r.NurseName, r.NurseFamily),
r.ScheduledDate,
r.SessionCount,
(forNurse ? r.NursePayoutAmount : r.GrossPriceIrr).ToString(),
r.DisputeWindowEndsAt,
r.CreatedAt))
.ToList();
return new PagedResult<BookingListItemDto>(items, total, page, pageSize);
}
public Task<BookingParticipants?> GetParticipantsAsync(long bookingId, CancellationToken cancellationToken)
=> (
from b in TableNoTracking
where b.Id == bookingId
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
join n in DbContext.Set<NurseProfile>() on b.NurseId equals n.Id
select new BookingParticipants(c.UserId, n.UserId))
.FirstOrDefaultAsync(cancellationToken);
public Task<Booking?> GetTrackedWithSessionsAsync(long id, CancellationToken cancellationToken)
=> Table.Include(b => b.Sessions).FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
public Task<Booking?> GetTrackedWithCareAsync(long id, CancellationToken cancellationToken)
=> Table.Include(b => b.CareInstructions).FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
public async Task<Booking?> GetTrackedBookingBySessionAsync(long sessionId, CancellationToken cancellationToken)
{
var bookingId = await DbContext.Set<BookingSession>()
.Where(s => s.Id == sessionId)
.Select(s => (long?)s.BookingId)
.FirstOrDefaultAsync(cancellationToken);
if (bookingId is not { } bid)
return null;
return await Table
.Include(b => b.Sessions).ThenInclude(s => s.Verification)
.FirstOrDefaultAsync(b => b.Id == bid, cancellationToken);
}
public Task<CareInstructionsGate?> GetCareInstructionsGateAsync(long bookingId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(b => b.Id == bookingId)
.Select(b => new CareInstructionsGate(
b.Status,
b.NurseId,
b.CustomerId,
b.CareInstructions == null
? null
: new CareInstructionsDto(
b.Id,
b.CareInstructions.CurrentConditions,
b.CareInstructions.Medications,
b.CareInstructions.Allergies,
b.CareInstructions.SpecialInstructions,
b.CareInstructions.EmergencyContactName,
b.CareInstructions.EmergencyContactPhone)))
.FirstOrDefaultAsync(cancellationToken);
public async Task<PagedResult<BookingSessionListItemDto>> ListSessionsForNurseAsync(
long nurseId, DateOnly? date, int page, int pageSize, CancellationToken cancellationToken)
{
var query = DbContext.Set<BookingSession>().AsNoTracking()
.Where(s => s.Booking.NurseId == nurseId);
if (date is { } d)
query = query.Where(s => s.ScheduledDate == d);
var total = await query.CountAsync(cancellationToken);
var items = await (
from s in query
join p in DbContext.Set<Patient>() on s.Booking.PatientId equals p.Id
orderby s.ScheduledDate, s.ScheduledTimeStart
select new BookingSessionListItemDto(
s.Id,
s.BookingId,
s.SessionIndex,
p.DisplayName,
s.ScheduledDate,
s.ScheduledTimeStart,
s.ScheduledTimeEnd,
s.Status,
s.Verification == null ? VisitVerificationStatus.Pending : s.Verification.Status))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new PagedResult<BookingSessionListItemDto>(items, total, page, pageSize);
}
public Task<EvvGate?> GetEvvForSessionAsync(long sessionId, CancellationToken cancellationToken)
=> DbContext.Set<BookingSession>().AsNoTracking()
.Where(s => s.Id == sessionId)
.Select(s => new EvvGate(
s.Booking.NurseId,
s.Booking.CustomerId,
s.Verification == null
? null
: new VisitVerificationDto(
s.Verification.Id,
s.Id,
s.Verification.Status,
s.Verification.CheckInAt,
s.Verification.CheckInLat,
s.Verification.CheckInLng,
s.Verification.CheckOutAt,
s.Verification.CheckOutLat,
s.Verification.CheckOutLng,
s.Verification.CheckInAddressMatch,
s.Verification.CheckInDistanceMeters)))
.FirstOrDefaultAsync(cancellationToken);
public async Task<PagedResult<AdminEvvItemDto>> ListAdminEvvAsync(string type, int page, int pageSize, CancellationToken cancellationToken)
{
var query = DbContext.Set<BookingSession>().AsNoTracking().AsQueryable();
// The two admin review lanes: an advisory location mismatch, or a no-show (missed) session.
query = type == "no_show"
? query.Where(s => s.Status == BookingSessionStatus.Missed)
: query.Where(s => s.Verification != null && s.Verification.CheckInAddressMatch == false);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(s => s.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(s => new AdminEvvItemDto(
s.Id,
s.BookingId,
s.Booking.NurseId,
s.Status,
s.ScheduledDate,
s.ScheduledTimeStart,
s.Verification == null ? null : s.Verification.CheckInAt,
s.Verification == null ? null : s.Verification.CheckInAddressMatch,
s.Verification == null ? null : s.Verification.CheckInDistanceMeters))
.ToListAsync(cancellationToken);
return new PagedResult<AdminEvvItemDto>(items, total, page, pageSize);
}
public async Task<IReadOnlyList<BookingSession>> GetNoShowCandidatesAsync(DateOnly today, int batchSize, CancellationToken cancellationToken)
=> await DbContext.Set<BookingSession>()
.Include(s => s.Booking)
.Where(s => s.Status == BookingSessionStatus.Scheduled && s.ScheduledDate <= today)
.OrderBy(s => s.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
private static string ComposeName(string? name, string? familyName)
=> string.Join(' ', new[] { name, familyName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
}
@@ -1,6 +1,7 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
@@ -166,6 +167,61 @@ internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequ
row.NurseRejectionReason, row.CreatedAt);
}
public Task<BookingRequest?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
public Task<BookingConversionSource?> GetConversionSourceAsync(long id, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.Id == id)
.Select(r => new BookingConversionSource(
r.Id,
r.Status,
r.CustomerId,
r.Customer.UserId,
r.NurseId,
r.Nurse.UserId,
r.PatientId,
r.Patient.DisplayName,
r.VariantId,
r.CustomerAddressId,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
new VariantSnapshot(
r.Variant.Id,
r.Variant.ServiceCategoryId,
r.Variant.ServiceCategory.NameFa,
r.Variant.ServiceCategory.NameEn,
r.Variant.Price,
r.Variant.PriceUnit,
r.Variant.SessionCount,
r.Variant.DisplayName,
r.Variant.Options
.Select(o => new VariantOptionDto(
o.OptionGroupId,
o.OptionGroup.NameFa,
o.OptionGroup.NameEn,
o.OptionValueId,
o.OptionValue.NameFa,
o.OptionValue.NameEn))
.ToList()),
new AddressSnapshot(
r.CustomerAddress.Id,
r.CustomerAddress.Title,
r.CustomerAddress.CityId,
r.CustomerAddress.City.NameFa,
r.CustomerAddress.City.NameEn,
r.CustomerAddress.DistrictId,
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameFa,
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameEn,
r.CustomerAddress.AddressLine,
r.CustomerAddress.PostalCode,
r.CustomerAddress.RecipientName,
r.CustomerAddress.RecipientPhone,
r.CustomerAddress.Latitude,
r.CustomerAddress.Longitude)))
.FirstOrDefaultAsync(cancellationToken);
// Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency
// (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation
// time anyway, and DateTimeOffset is not sortable on the SQLite test provider.
@@ -0,0 +1,36 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CancellationPolicyRepository : BaseAsyncRepository<CancellationPolicy>, ICancellationPolicyRepository
{
public CancellationPolicyRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public async Task<IReadOnlyList<CancellationPolicy>> GetActiveForActorAsync(string appliesTo, CancellationToken cancellationToken)
=> await TableNoTracking
.Where(p => p.AppliesTo == appliesTo && p.IsActive)
// Tighter (bounded) buckets first so the resolver prefers the most specific covering tier.
.OrderBy(p => p.HoursBeforeStartMin ?? int.MinValue)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<CancellationPolicyDto>> ListAsync(CancellationToken cancellationToken)
=> await TableNoTracking
.OrderBy(p => p.AppliesTo).ThenBy(p => p.Code)
.Select(p => new CancellationPolicyDto(
p.Id, p.Code, p.AppliesTo, p.HoursBeforeStartMin, p.HoursBeforeStartMax,
p.RefundPercentage, p.FeeAmountIrr.ToString(), p.FeeRate, p.IsActive))
.ToListAsync(cancellationToken);
public Task<CancellationPolicy?> GetTrackedByCodeAsync(string code, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.Code == code, cancellationToken);
public Task AddAsync(CancellationPolicy policy, CancellationToken cancellationToken)
=> base.AddAsync(policy);
}
@@ -20,6 +20,8 @@ public class UnitOfWork : IUnitOfWork
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public IBookingRequestRepository BookingRequestRepository { get; }
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -38,6 +40,8 @@ public class UnitOfWork : IUnitOfWork
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
VerificationRepository = new VerificationRepository(_db);
BookingRequestRepository = new BookingRequestRepository(_db);
BookingRepository = new BookingRepository(_db);
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class AdminCancellationPoliciesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/admin_cancellation_policies/list");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task List_AsAdmin_ReturnsSeededTiers()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131900401");
var response = await admin.GetAsync("/api/v1/admin_cancellation_policies/list");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
// The four baseline tiers are seeded via HasData.
Assert.True(data.GetArrayLength() >= 4);
}
[Fact]
public async Task Upsert_InvalidPercentage_Returns400()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131900402");
var response = await admin.PostAsJsonAsync("/api/v1/admin_cancellation_policies/upsert", new
{
code = "custom_tier",
appliesTo = "customer",
refundPercentage = 150,
feeAmountIrr = 0,
isActive = true
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Upsert_AsAdmin_CreatesTier()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131900403");
var response = await admin.PostAsJsonAsync("/api/v1/admin_cancellation_policies/upsert", new
{
code = "late_customer",
appliesTo = "customer",
hoursBeforeStartMin = 0,
hoursBeforeStartMax = 6,
refundPercentage = 25,
feeAmountIrr = 0,
isActive = true
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal("late_customer", data.GetProperty("code").GetString());
}
}
@@ -0,0 +1,40 @@
using System.Net;
namespace Baya.Test.Api;
public class AdminEvvApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/admin_evv/list?type=mismatch");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task List_AsAdmin_ReturnsEmptyPagedEnvelope()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131900301");
var response = await admin.GetAsync("/api/v1/admin_evv/list?type=mismatch");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("total").GetInt32());
}
[Fact]
public async Task DetectNoShows_AsAdmin_ReturnsZeroOnEmptySet()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131900302");
var response = await admin.PostAsync("/api/v1/admin_evv/detect_no_shows", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("missed").GetInt32());
}
}
@@ -0,0 +1,40 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class BookingSessionsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task CheckIn_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/booking_sessions/check_in/1", new { latitude = 35.6m, longitude = 51.3m });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Today_AsNurse_ReturnsEmptyPagedEnvelope()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131900201", "nurse");
var response = await client.GetAsync("/api/v1/booking_sessions/today");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("total").GetInt32());
}
[Fact]
public async Task CheckIn_UnknownSession_ReturnsNotFound()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131900202", "nurse");
// The role-selected nurse has no nurse profile yet (403) and the session is absent (404) — either
// way the check-in never succeeds and never leaks another booking's session.
var response = await client.PostAsJsonAsync("/api/v1/booking_sessions/check_in/999999", new { latitude = 35.6m, longitude = 51.3m });
Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden);
}
}
@@ -0,0 +1,50 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class BookingsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Convert_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/bookings/convert", new { bookingRequestId = 1 });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Convert_InvalidId_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131900101", "customer");
var response = await client.PostAsJsonAsync("/api/v1/bookings/convert", new { bookingRequestId = 0 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task List_Authenticated_ReturnsEmptyPagedEnvelope()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131900102", "customer");
var response = await client.GetAsync("/api/v1/bookings/list");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("total").GetInt32());
}
[Fact]
public async Task CareInstructions_UnauthorizedCaller_DoesNotLeak()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131900103", "customer");
// The two-stage disclosure boundary: a caller who is not the assigned nurse/admin gets a clean
// not-found for a booking they cannot read (here, a non-existent one) — never the clinical fields.
var response = await client.GetAsync("/api/v1/bookings/care_instructions/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
@@ -0,0 +1,50 @@
using Baya.Domain.Entities.Booking;
namespace Baya.Test.Foundation.Bookings;
public class BookingAmountsTests
{
[Theory]
[InlineData(10_000_000, 0.15, 1_500_000, 8_500_000)]
[InlineData(5_000_000, 0.15, 750_000, 4_250_000)]
// Rounding half away from zero: 3 × 0.15 = 0.45 → 0 (commission), payout 3.
[InlineData(3, 0.15, 0, 3)]
public void Split_reconciles_gross_equals_commission_plus_payout(long gross, decimal rate, long expectedCommission, long expectedPayout)
{
var (commission, payout) = BookingAmounts.Split(gross, rate);
Assert.Equal(expectedCommission, commission);
Assert.Equal(expectedPayout, payout);
Assert.Equal(gross, commission + payout);
Assert.True(commission >= 0 && payout >= 0);
}
[Theory]
[InlineData(8_500_000, 1)]
[InlineData(8_500_000, 3)]
[InlineData(8_500_001, 3)]
[InlineData(10, 3)]
[InlineData(2, 3)]
public void SplitPayout_sums_exactly_to_payout_with_remainder_on_last(long payout, int sessions)
{
var parts = BookingAmounts.SplitPayout(payout, sessions);
Assert.Equal(sessions, parts.Length);
Assert.Equal(payout, parts.Sum());
Assert.All(parts, p => Assert.True(p >= 0));
// Equal shares except the last, which carries the remainder.
var per = payout / sessions;
for (var i = 0; i < sessions - 1; i++)
Assert.Equal(per, parts[i]);
Assert.Equal(payout - per * (sessions - 1), parts[^1]);
}
[Fact]
public void SplitPayout_single_visit_returns_whole_amount()
{
var parts = BookingAmounts.SplitPayout(4_250_000, 1);
Assert.Single(parts);
Assert.Equal(4_250_000, parts[0]);
}
}
@@ -0,0 +1,97 @@
using System.Globalization;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bookings;
public class BookingConversionTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
private static ConvertRequestToBookingCommandHandler Handler(BookingsTestHost host, IPaymentCaptureSimulator capture)
=> new(host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), capture, new VariantSnapshotSerializer(),
Substitute.For<INotificationDispatcher>());
[Fact]
public async Task Convert_creates_confirmed_booking_with_reconciling_amounts_and_sessions()
{
using var host = new BookingsTestHost();
var variantId = host.AddVariant(sessionCount: 3, price: 5_000_000); // gross = 15_000_000
var requestId = host.AddAcceptedRequest(variantId);
var result = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
Assert.True(result.IsSuccess);
var dto = result.Result;
Assert.Equal(BookingStatus.Confirmed, dto.Status);
var gross = long.Parse(dto.GrossPriceIrr, CultureInfo.InvariantCulture);
var commission = long.Parse(dto.BalinyaarCommissionIrr, CultureInfo.InvariantCulture);
var payout = long.Parse(dto.NursePayoutAmount, CultureInfo.InvariantCulture);
Assert.Equal(15_000_000, gross);
Assert.Equal(2_250_000, commission); // 15M × 0.15
Assert.Equal(gross, commission + payout);
Assert.Equal(0.15m, dto.PlatformFeeRate);
Assert.NotNull(dto.AddressSnapshotJson);
Assert.Contains("خیابان اول", dto.AddressSnapshotJson!); // snapshot decrypted for the customer view
Assert.Equal((short)3, dto.SessionCount);
Assert.Equal(3, dto.Sessions.Count);
var sessionPayoutSum = dto.Sessions.Sum(s => long.Parse(s.VisitPayoutAmount, CultureInfo.InvariantCulture));
Assert.Equal(payout, sessionPayoutSum);
// Request flipped to converted.
var reqStatus = host.Db.Set<BookingRequest>().AsNoTracking().Single(r => r.Id == requestId).Status;
Assert.Equal(BookingRequestStatus.Converted, reqStatus);
}
[Fact]
public async Task Convert_single_visit_creates_exactly_one_session()
{
using var host = new BookingsTestHost();
var variantId = host.AddVariant(sessionCount: null); // single visit
var requestId = host.AddAcceptedRequest(variantId);
var result = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal((short)1, result.Result.SessionCount);
Assert.Single(result.Result.Sessions);
}
[Fact]
public async Task Convert_is_idempotent_returns_same_booking_on_replay()
{
using var host = new BookingsTestHost();
var variantId = host.AddVariant(sessionCount: 2);
var requestId = host.AddAcceptedRequest(variantId);
var first = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
var second = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.True(second.IsSuccess);
Assert.Equal(first.Result.Id, second.Result.Id);
Assert.Equal(1, host.Db.Set<BookingEntity>().Count());
}
[Fact]
public async Task Convert_with_failed_capture_creates_no_booking_and_leaves_request_accepted()
{
using var host = new BookingsTestHost();
var variantId = host.AddVariant(sessionCount: 1);
var requestId = host.AddAcceptedRequest(variantId);
var result = await Handler(host, host.Capture(succeeds: false)).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(0, host.Db.Set<BookingEntity>().Count());
var reqStatus = host.Db.Set<BookingRequest>().AsNoTracking().Single(r => r.Id == requestId).Status;
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, reqStatus);
}
}
@@ -0,0 +1,100 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Bookings.Commands.CheckInVisit;
using Baya.Application.Features.Bookings.Commands.CheckOutVisit;
using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.SupportAlerts;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bookings;
public class BookingEvvTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 9, 5, 0, TimeSpan.Zero);
private static async Task<(long BookingId, long SessionId)> ConvertSingleAsync(BookingsTestHost host)
{
var variantId = host.AddVariant(sessionCount: 1);
var requestId = host.AddAcceptedRequest(variantId);
var handler = new ConvertRequestToBookingCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), host.Capture(),
new VariantSnapshotSerializer(), Substitute.For<INotificationDispatcher>());
var result = await handler.Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
return (result.Result.Id, result.Result.Sessions[0].Id);
}
private IGeocoder GeocoderAt(BookingsTestHost host)
{
var g = Substitute.For<IGeocoder>();
g.GeocodeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new GeocodeResult(host.AddressLat, host.AddressLng, "formatted", 0.9));
return g;
}
[Fact]
public async Task CheckIn_in_range_then_checkout_completes_booking_and_sets_dispute_window()
{
using var host = new BookingsTestHost();
var (bookingId, sessionId) = await ConvertSingleAsync(host);
var alerts = Substitute.For<ISupportAlertService>();
var checkIn = new CheckInVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(Now),
GeocoderAt(host), alerts, Substitute.For<INotificationDispatcher>());
var inResult = await checkIn.Handle(new CheckInVisitCommand(host.AddressLat, host.AddressLng, sessionId), CancellationToken.None);
Assert.True(inResult.IsSuccess);
Assert.True(inResult.Result.CheckInAddressMatch); // in range
Assert.Equal(BookingStatus.InProgress, host.StatusOf(bookingId));
await alerts.DidNotReceive().RaiseAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
var checkOutTime = new DateTimeOffset(2026, 8, 1, 13, 0, 0, TimeSpan.Zero);
var checkOut = new CheckOutVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(checkOutTime));
var outResult = await checkOut.Handle(new CheckOutVisitCommand(host.AddressLat, host.AddressLng, sessionId), CancellationToken.None);
Assert.True(outResult.IsSuccess);
Assert.Equal(VisitVerificationStatus.Completed, outResult.Result.Status);
Assert.Equal(BookingStatus.Completed, host.StatusOf(bookingId));
var booking = host.Db.Set<BookingEntity>().Include(b => b.Sessions).AsNoTracking().Single(b => b.Id == bookingId);
Assert.Equal(checkOutTime.UtcDateTime.AddHours(72), booking.DisputeWindowEndsAt);
Assert.Equal(checkOutTime.UtcDateTime.AddHours(72), booking.Sessions.Single().PayoutEligibleAt);
}
[Fact]
public async Task CheckIn_out_of_range_raises_alert_without_blocking()
{
using var host = new BookingsTestHost();
var (bookingId, sessionId) = await ConvertSingleAsync(host);
var alerts = Substitute.For<ISupportAlertService>();
var checkIn = new CheckInVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(Now),
GeocoderAt(host), alerts, Substitute.For<INotificationDispatcher>());
// Far-away GPS (Gulf of Guinea) → well beyond tolerance.
var result = await checkIn.Handle(new CheckInVisitCommand(0m, 0m, sessionId), CancellationToken.None);
Assert.True(result.IsSuccess); // never blocked
Assert.False(result.Result.CheckInAddressMatch); // flagged
Assert.Equal(BookingStatus.InProgress, host.StatusOf(bookingId)); // visit proceeds
await alerts.Received(1).RaiseAsync(
SupportAlertType.EvvLocationMismatch, "booking_session", sessionId.ToString(),
SupportAlertSeverity.Medium, bookingId, Arg.Any<long?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task CheckOut_without_open_checkin_fails()
{
using var host = new BookingsTestHost();
var (_, sessionId) = await ConvertSingleAsync(host);
var checkOut = new CheckOutVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(Now));
var result = await checkOut.Handle(new CheckOutVisitCommand(host.AddressLat, host.AddressLng, sessionId), CancellationToken.None);
Assert.False(result.IsSuccess);
}
}
@@ -0,0 +1,63 @@
using Baya.Domain.Entities.Booking;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bookings;
public class BookingTransitionsTests
{
[Theory]
[InlineData(BookingStatus.PendingPayment, BookingStatus.Confirmed, true)]
[InlineData(BookingStatus.PendingPayment, BookingStatus.Cancelled, true)]
[InlineData(BookingStatus.Confirmed, BookingStatus.InProgress, true)]
[InlineData(BookingStatus.InProgress, BookingStatus.Completed, true)]
[InlineData(BookingStatus.Completed, BookingStatus.Disputed, true)]
[InlineData(BookingStatus.Completed, BookingStatus.Closed, true)]
[InlineData(BookingStatus.Disputed, BookingStatus.Closed, true)]
// Illegal edges.
[InlineData(BookingStatus.Confirmed, BookingStatus.Completed, false)]
[InlineData(BookingStatus.PendingPayment, BookingStatus.InProgress, false)]
[InlineData(BookingStatus.Closed, BookingStatus.InProgress, false)]
[InlineData(BookingStatus.Cancelled, BookingStatus.Confirmed, false)]
public void Booking_transition_guard(string from, string to, bool allowed)
=> Assert.Equal(allowed, BookingTransitions.CanTransition(from, to));
[Theory]
[InlineData(BookingSessionStatus.Scheduled, BookingSessionStatus.InProgress, true)]
[InlineData(BookingSessionStatus.Scheduled, BookingSessionStatus.Cancelled, true)]
[InlineData(BookingSessionStatus.Scheduled, BookingSessionStatus.Missed, true)]
[InlineData(BookingSessionStatus.InProgress, BookingSessionStatus.Completed, true)]
[InlineData(BookingSessionStatus.Completed, BookingSessionStatus.InProgress, false)]
[InlineData(BookingSessionStatus.Missed, BookingSessionStatus.Completed, false)]
public void Session_transition_guard(string from, string to, bool allowed)
=> Assert.Equal(allowed, BookingSessionTransitions.CanTransition(from, to));
[Fact]
public void Booking_TransitionTo_stamps_timestamps_and_rejects_illegal_edge()
{
var booking = new BookingEntity();
var now = new DateTime(2026, 7, 6, 10, 0, 0, DateTimeKind.Utc);
booking.TransitionTo(BookingStatus.Confirmed, now);
Assert.Equal(BookingStatus.Confirmed, booking.Status);
Assert.Equal(now, booking.ConfirmedAt);
Assert.Throws<InvalidOperationException>(() => { booking.TransitionTo(BookingStatus.Completed, now); });
booking.TransitionTo(BookingStatus.InProgress, now);
booking.TransitionTo(BookingStatus.Completed, now);
Assert.Equal(now, booking.CompletedAt);
}
[Theory]
[InlineData(48, true, false)] // ≥ 24h → standard_24h
[InlineData(10, false, true)] // < 24h → standard_inside_24h
[InlineData(-3, false, true)] // already started → still inside-24h bucket (open lower bound)
public void Customer_policy_buckets_are_non_overlapping_and_total(double hoursBefore, bool covers24h, bool coversInside)
{
var full = new CancellationPolicy { HoursBeforeStartMin = 24, HoursBeforeStartMax = null };
var inside = new CancellationPolicy { HoursBeforeStartMin = null, HoursBeforeStartMax = 24 };
Assert.Equal(covers24h, full.Covers(hoursBefore));
Assert.Equal(coversInside, inside.Covers(hoursBefore));
}
}
@@ -0,0 +1,194 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bookings;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (schema, filtered indexes, CHECK, query
/// filters, HasData seeds incl. the cancellation-policy tiers) for the b9 booking engine. Seeds one bookable
/// nurse and one customer (with a geocoded address), and lets a test create an accepted request, convert it,
/// and drive the real <see cref="UnitOfWork"/> against real handlers with substituted seams.
/// </summary>
public sealed class BookingsTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
public int NurseUserId { get; }
public long PatientId { get; }
public long AddressId { get; }
// The seeded address coordinates — the EVV distance check resolves the booking address here.
public decimal AddressLat => 35.6892m;
public decimal AddressLng => 51.3890m;
private readonly long _cityId;
public BookingsTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
Db.SaveChanges();
_cityId = city.Id;
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
CategoryId = category.Id;
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
Db.SaveChanges();
PatientId = patient.Id;
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه",
AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
Latitude = AddressLat, Longitude = AddressLng, IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
AddressId = address.Id;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
NurseUserId = nurseUser.Id;
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
}
public long CategoryId { get; }
/// <summary>Adds a bookable variant; <paramref name="sessionCount"/> null = single visit.</summary>
public long AddVariant(int? sessionCount, long price = 5_000_000)
{
var variant = new NurseServiceVariant
{
NurseId = NurseId, ServiceCategoryId = CategoryId, Price = price, PriceUnit = "per_day",
SessionCount = sessionCount, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
return variant.Id;
}
/// <summary>Creates an <c>accepted_awaiting_payment</c> request against the given variant.</summary>
public long AddAcceptedRequest(long variantId)
{
var request = new BookingRequest
{
CustomerId = CustomerId, NurseId = NurseId, PatientId = PatientId, VariantId = variantId,
CustomerAddressId = AddressId, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Db.SaveChanges();
return request.Id;
}
public ICurrentUser AsCustomer()
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(CustomerUserId);
u.Roles.Returns(new[] { RoleNames.Customer });
return u;
}
public ICurrentUser AsNurse(int? userId = null)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(userId ?? NurseUserId);
u.Roles.Returns(new[] { RoleNames.Nurse });
return u;
}
public ICurrentUser AsAdmin(int userId = 9999)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(userId);
u.Roles.Returns(new[] { RoleNames.Admin });
return u;
}
public IDateTimeProvider Clock(DateTimeOffset now)
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(now);
return c;
}
public IPlatformConfig Config(decimal feeRate = 0.15m, int disputeWindowHours = 72, int evvToleranceMeters = 200, int noShowThresholdMinutes = 60)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(feeRate);
cfg.GetConfig<int>("dispute_window_hours", Arg.Any<CancellationToken>()).Returns(disputeWindowHours);
cfg.GetConfig<int>("evv_location_tolerance_meters", Arg.Any<CancellationToken>()).Returns(evvToleranceMeters);
cfg.GetConfig<int>("no_show_threshold_minutes", Arg.Any<CancellationToken>()).Returns(noShowThresholdMinutes);
return cfg;
}
public IPaymentCaptureSimulator Capture(bool succeeds = true, long? pspFee = 12_000)
{
var c = Substitute.For<IPaymentCaptureSimulator>();
c.ConfirmCaptureAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(new PaymentCaptureResult(succeeds, succeeds ? "ref" : string.Empty, succeeds ? pspFee : null));
return c;
}
public string StatusOf(long bookingId)
=> Db.Set<BookingEntity>().AsNoTracking().Where(b => b.Id == bookingId).Select(b => b.Status).Single();
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,66 @@
using System.Globalization;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Bookings.Commands.CancelBooking;
using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bookings;
public class CancellationTests
{
// Well before the 2026-08-01 engagement start → the ≥24h customer tier (100% refund).
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
private static async Task<long> ConvertAsync(BookingsTestHost host, int sessions)
{
var variantId = host.AddVariant(sessionCount: sessions, price: 5_000_000);
var requestId = host.AddAcceptedRequest(variantId);
var handler = new ConvertRequestToBookingCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), host.Capture(),
new VariantSnapshotSerializer(), Substitute.For<INotificationDispatcher>());
var result = await handler.Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
return result.Result.Id;
}
[Fact]
public async Task Cancel_resolves_snapshots_policy_and_refunds_unstarted_sessions()
{
using var host = new BookingsTestHost();
var bookingId = await ConvertAsync(host, sessions: 3); // gross 15M, all un-started
var handler = new CancelBookingCommandHandler(host.AsCustomer(), host.UnitOfWork, host.Clock(Now));
var result = await handler.Handle(new CancelBookingCommand("changed plans", bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(CancellationPolicyCode.Standard24h, result.Result.PolicyCode);
Assert.Equal(100m, result.Result.RefundPercentage);
Assert.Equal(15_000_000, long.Parse(result.Result.RefundableAmountIrr, CultureInfo.InvariantCulture));
Assert.Equal(BookingStatus.Cancelled, host.StatusOf(bookingId));
var sessions = host.Db.Set<BookingSession>().AsNoTracking().Where(s => s.BookingId == bookingId).ToList();
Assert.All(sessions, s => Assert.Equal(BookingSessionStatus.Cancelled, s.Status));
}
[Fact]
public async Task Cancellation_snapshot_is_immune_to_a_later_policy_edit()
{
using var host = new BookingsTestHost();
var bookingId = await ConvertAsync(host, sessions: 1);
var handler = new CancelBookingCommandHandler(host.AsCustomer(), host.UnitOfWork, host.Clock(Now));
await handler.Handle(new CancelBookingCommand("changed plans", bookingId), CancellationToken.None);
// Editing the underlying policy afterward must not change the frozen snapshot.
var policy = host.Db.Set<CancellationPolicy>().Single(p => p.Code == CancellationPolicyCode.Standard24h);
policy.RefundPercentage = 10m;
host.Db.SaveChanges();
var booking = host.Db.Set<BookingEntity>().AsNoTracking().Single(b => b.Id == bookingId);
Assert.Equal(100m, booking.CancellationRefundPercentage);
Assert.Equal(CancellationPolicyCode.Standard24h, booking.CancellationPolicyCode);
}
}
@@ -0,0 +1,59 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
using Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
using Baya.Application.Features.Bookings.Queries.GetCareInstructions;
using NSubstitute;
namespace Baya.Test.Foundation.Bookings;
public class CareInstructionsDisclosureTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
private static async Task<long> ConvertAsync(BookingsTestHost host)
{
var variantId = host.AddVariant(sessionCount: 1);
var requestId = host.AddAcceptedRequest(variantId);
var handler = new ConvertRequestToBookingCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), host.Capture(),
new VariantSnapshotSerializer(), Substitute.For<INotificationDispatcher>());
var result = await handler.Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None);
return result.Result.Id;
}
[Fact]
public async Task Care_instructions_are_gated_to_assigned_nurse_and_admin_only()
{
using var host = new BookingsTestHost();
var bookingId = await ConvertAsync(host);
// Customer authors the encrypted stage-2 instructions.
var submit = new SubmitCareInstructionsCommandHandler(host.AsCustomer(), host.UnitOfWork);
var submitResult = await submit.Handle(
new SubmitCareInstructionsCommand("diabetes", "insulin", "penicillin", "ring twice", "Sara", "0912", bookingId),
CancellationToken.None);
Assert.True(submitResult.IsSuccess);
// Assigned nurse → decrypted fields.
var asNurse = new GetCareInstructionsQueryHandler(host.AsNurse(), host.UnitOfWork);
var nurseRead = await asNurse.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None);
Assert.True(nurseRead.IsSuccess);
Assert.Equal("insulin", nurseRead.Result.Medications);
// Admin → decrypted fields.
var asAdmin = new GetCareInstructionsQueryHandler(host.AsAdmin(), host.UnitOfWork);
var adminRead = await asAdmin.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None);
Assert.True(adminRead.IsSuccess);
// The customer (author) cannot read them back — the disclosure is the nurse's.
var asCustomer = new GetCareInstructionsQueryHandler(host.AsCustomer(), host.UnitOfWork);
var customerRead = await asCustomer.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None);
Assert.True(customerRead.IsNotFound);
// An unassigned nurse (no matching profile) cannot read them.
var asOtherNurse = new GetCareInstructionsQueryHandler(host.AsNurse(userId: 987654), host.UnitOfWork);
var otherRead = await asOtherNurse.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None);
Assert.True(otherRead.IsNotFound);
}
}