backend phase 9
This commit is contained in:
+42
-6
@@ -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);
|
||||
}
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
+69
@@ -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()));
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+62
@@ -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()));
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+154
@@ -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);
|
||||
}
|
||||
+13
@@ -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>>;
|
||||
+77
@@ -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!);
|
||||
}
|
||||
}
|
||||
+13
@@ -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>>;
|
||||
+164
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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>>;
|
||||
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+63
@@ -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));
|
||||
}
|
||||
}
|
||||
+18
@@ -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.
|
||||
}
|
||||
}
|
||||
+17
@@ -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>>;
|
||||
+62
@@ -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));
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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>>;
|
||||
+37
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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.");
|
||||
}
|
||||
}
|
||||
+19
@@ -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>>;
|
||||
+38
@@ -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.");
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>;
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>;
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>>;
|
||||
+52
@@ -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);
|
||||
}
|
||||
+11
@@ -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>>>;
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>>;
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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; > 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> (0–100), 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, 0–100.</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, < 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";
|
||||
}
|
||||
+28
@@ -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>
|
||||
|
||||
+5
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+4192
File diff suppressed because it is too large
Load Diff
+379
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+532
@@ -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");
|
||||
|
||||
+272
@@ -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();
|
||||
}
|
||||
+56
@@ -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.
|
||||
|
||||
+36
@@ -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);
|
||||
}
|
||||
+4
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user