backend phase 9

This commit is contained in:
hamid
2026-07-06 19:23:44 +03:30
parent 2cfc082a04
commit 12c7e51c32
101 changed files with 11666 additions and 8 deletions
@@ -0,0 +1,26 @@
namespace Baya.Application.Common;
/// <summary>
/// Great-circle distance between two lat/lng points, in metres (haversine). Used by the EVV check-in
/// address-match: the nurse's captured GPS is compared against the booking address coordinates, and the
/// result is <b>advisory only</b> — a mismatch flags admin review, never blocks the visit.
/// </summary>
public static class GeoDistance
{
private const double EarthRadiusMeters = 6_371_000d;
public static double HaversineMeters(double lat1, double lng1, double lat2, double lng2)
{
var dLat = ToRadians(lat2 - lat1);
var dLng = ToRadians(lng2 - lng1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
+ Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2))
* Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return EarthRadiusMeters * c;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180d;
}
@@ -0,0 +1,21 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Temporary seam standing in for b10's real card capture, so <c>ConvertRequestToBookingCommand</c> is
/// testable now. <see cref="ConfirmCaptureAsync"/> returns a deterministic <i>succeeded</i> capture (a fake
/// gateway reference + optional PSP fee); a config switch can force a <i>failed</i> capture so the
/// "capture failed → no booking" path is testable. <b>This is the temporary conversion trigger, not a
/// parallel money path:</b> in b10 the real capture calls <c>ConvertRequestToBooking</c> directly after a
/// real <c>payment_transactions.succeeded</c> and this seam is removed.
/// </summary>
public interface IPaymentCaptureSimulator
{
ValueTask<PaymentCaptureResult> ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default);
}
/// <summary>The outcome of a (mock) payment capture. On failure, <see cref="GatewayReference"/> is empty.</summary>
/// <param name="Succeeded">Whether the capture succeeded — conversion runs only when true.</param>
/// <param name="GatewayReference">The gateway's capture reference (fake in the mock).</param>
/// <param name="PspFeeAmount">The gateway cost on this payment (IRR), for true margin. Null when unknown.</param>
public sealed record PaymentCaptureResult(bool Succeeded, string GatewayReference, long? PspFeeAmount);
@@ -0,0 +1,56 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The post-payment <c>bookings</c> aggregate (bookings + sessions + care instructions + EVV). Writes load
/// tracked rows; reads project to role-scoped DTOs. Money is IRR <c>long</c> throughout; the encrypted
/// address snapshot and care fields decrypt only on the gated read paths. Tenancy is enforced in the
/// handlers from the ids these projections carry — a cross-party access is a clean not-found, never a leak.
/// </summary>
public interface IBookingRepository
{
// ---- conversion ----
Task AddAsync(Booking booking, CancellationToken cancellationToken);
/// <summary>The booking id already created from this request, if any — the idempotency check that makes
/// a replayed conversion return the existing booking instead of creating a second one.</summary>
Task<long?> GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
// ---- detail + lists ----
Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
Task<PagedResult<BookingListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken);
Task<PagedResult<BookingListItemDto>> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken);
Task<PagedResult<BookingListItemDto>> ListAllAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Both participant user ids for a booking — the notification recipients. Null when absent.</summary>
Task<BookingParticipants?> GetParticipantsAsync(long bookingId, CancellationToken cancellationToken);
// ---- tracked loads for writes ----
/// <summary>Tracked booking with its sessions loaded — for the transition guard and whole-booking cancel.</summary>
Task<Booking?> GetTrackedWithSessionsAsync(long id, CancellationToken cancellationToken);
/// <summary>Tracked booking with its 1:1 care instructions loaded — for the care-instructions upsert.</summary>
Task<Booking?> GetTrackedWithCareAsync(long id, CancellationToken cancellationToken);
/// <summary>The tracked booking that owns <paramref name="sessionId"/>, with all its sessions and their
/// EVV records loaded, so a check-in/out/cancel can mutate the target session, create/complete its EVV,
/// and evaluate the sibling sessions for booking completion. Null if the session is absent.</summary>
Task<Booking?> GetTrackedBookingBySessionAsync(long sessionId, CancellationToken cancellationToken);
// ---- care instructions (gated read) ----
Task<CareInstructionsGate?> GetCareInstructionsGateAsync(long bookingId, CancellationToken cancellationToken);
// ---- sessions + EVV ----
Task<PagedResult<BookingSessionListItemDto>> ListSessionsForNurseAsync(long nurseId, DateOnly? date, int page, int pageSize, CancellationToken cancellationToken);
Task<EvvGate?> GetEvvForSessionAsync(long sessionId, CancellationToken cancellationToken);
Task<PagedResult<AdminEvvItemDto>> ListAdminEvvAsync(string type, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Tracked scheduled sessions on or before <paramref name="today"/> with their booking loaded —
/// the no-show sweep filters by the exact start-plus-threshold instant in memory (a DateOnly+TimeOnly
/// combine is not translatable), then marks the overdue ones missed.</summary>
Task<IReadOnlyList<BookingSession>> GetNoShowCandidatesAsync(DateOnly today, int batchSize, CancellationToken cancellationToken);
}
@@ -39,4 +39,14 @@ public interface IBookingRequestRepository
/// <summary>Role-agnostic detail projection (carries both party ids for authorization + the full address
/// for the customer/admin path). NULL when absent.</summary>
Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
/// <summary>Tracked lookup by id with <b>no</b> tenancy scope — the b9 conversion runs from a payment
/// capture (a system/admin path), and flips the request to <c>converted</c> in the same unit of work.
/// NULL when absent.</summary>
Task<BookingRequest?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>Everything b9 needs to convert an <c>accepted_awaiting_payment</c> request into a booking in
/// one projected read: ids + participant user ids, the engagement schedule, and the source data for the
/// two frozen snapshots (variant + decrypted address). NULL when absent.</summary>
Task<BookingConversionSource?> GetConversionSourceAsync(long id, CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// Admin-managed cancellation/refund tiers. The resolver picks the active tier for an
/// <c>(applies_to, lead-time bucket)</c> at cancel time; its <c>code</c> + <c>refund_percentage</c> are then
/// frozen onto the booking (a later edit here never mutates a past cancellation).
/// </summary>
public interface ICancellationPolicyRepository
{
/// <summary>The active tiers for an actor, ordered so the resolver can pick the first covering bucket.</summary>
Task<IReadOnlyList<CancellationPolicy>> GetActiveForActorAsync(string appliesTo, CancellationToken cancellationToken);
Task<IReadOnlyList<CancellationPolicyDto>> ListAsync(CancellationToken cancellationToken);
/// <summary>Tracked lookup by unique <c>code</c> for the admin upsert; null when the code is new.</summary>
Task<CancellationPolicy?> GetTrackedByCodeAsync(string code, CancellationToken cancellationToken);
Task AddAsync(CancellationPolicy policy, CancellationToken cancellationToken);
}
@@ -16,6 +16,8 @@ public interface IUnitOfWork
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public IBookingRequestRepository BookingRequestRepository { get; }
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,66 @@
#nullable enable
using System.Globalization;
using Baya.Application.Models.Booking;
namespace Baya.Application.Features.Bookings;
/// <summary>
/// Maps the role-agnostic booking projection to the wire DTO. Money crosses as strings of IRR-Rial digits.
/// <paramref name="includeAddress"/> is the disclosure switch: the owning customer (and admin) receive the
/// decrypted address snapshot; the nurse view omits it (a booking's coarse location suffices for the nurse,
/// the full address is surfaced only through EVV/session flows). Care-instruction clinical fields are never
/// part of this DTO.
/// </summary>
internal static class BookingMapper
{
public static BookingDetailDto ToDetailDto(BookingDetailProjection p, bool includeAddress)
=> new(
p.Id,
p.BookingRequestId,
p.Status,
p.NurseId,
p.NurseName,
p.PatientId,
p.PatientName,
p.VariantId,
p.VariantSnapshotJson,
p.CustomerAddressId,
includeAddress ? p.AddressSnapshotJson : null,
Money(p.GrossPriceIrr),
Money(p.BalinyaarCommissionIrr),
p.PlatformFeeRate,
Money(p.NursePayoutAmount),
p.PspFeeAmount is { } psp ? Money(psp) : null,
p.SessionCount,
p.ScheduledDate,
p.ScheduledTimeStart,
p.ScheduledTimeEnd,
p.ConfirmedAt,
p.CompletedAt,
p.CancelledAt,
p.CancelledBy,
p.CancellationReason,
p.CancellationPolicyCode,
p.CancellationRefundPercentage,
p.RefundableAmountIrr is { } refund ? Money(refund) : null,
p.DisputeWindowEndsAt,
p.CreatedAt,
p.Sessions.Select(ToSessionSummary).ToList());
private static BookingSessionSummaryDto ToSessionSummary(BookingSessionProjection s)
=> new(
s.Id,
s.SessionIndex,
s.ScheduledDate,
s.ScheduledTimeStart,
s.ScheduledTimeEnd,
s.Status,
Money(s.VisitPayoutAmount),
s.PayoutEligibleAt,
s.EvvStatus ?? Domain.Entities.Booking.VisitVerificationStatus.Pending,
s.CheckInAt,
s.CheckOutAt,
s.CheckInAddressMatch);
private static string Money(long irr) => irr.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,10 @@
using Baya.Domain.Entities.User;
namespace Baya.Application.Features.Bookings;
/// <summary>The admin role set that may read any booking / EVV detail and drive admin-only transitions.</summary>
internal static class BookingRoles
{
public static readonly string[] Admin =
[RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation];
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Features.Bookings;
/// <summary>
/// Shared cancellation resolution + refund arithmetic for the whole-booking and single-session cancel paths.
/// The applicable policy is resolved by <c>(actor, lead-time bucket)</c>; only un-started (still
/// <c>scheduled</c>) sessions are refundable; the refund is a percentage of those sessions' share of gross.
/// </summary>
internal static class CancellationHelper
{
public static string ResolveActor(bool isAdmin, bool isAssignedNurse)
=> isAdmin ? CancellationActor.Admin
: isAssignedNurse ? CancellationActor.Nurse
: CancellationActor.Customer;
/// <summary>Hours from <paramref name="nowUtc"/> to the scheduled start (negative once it has started).</summary>
public static double HoursBeforeStart(DateOnly date, TimeOnly start, DateTime nowUtc)
=> (date.ToDateTime(start, DateTimeKind.Utc) - nowUtc).TotalHours;
public static CancellationPolicy ResolvePolicy(IReadOnlyList<CancellationPolicy> policies, double hoursBeforeStart)
=> policies.FirstOrDefault(p => p.Covers(hoursBeforeStart));
/// <summary>The refundable amount (IRR) = <paramref name="refundPercentage"/> of the un-started sessions'
/// share of gross, rounded half away from zero.</summary>
public static long Refundable(long refundableGrossBase, decimal refundPercentage)
=> (long)decimal.Round(refundableGrossBase * refundPercentage / 100m, MidpointRounding.AwayFromZero);
}
@@ -0,0 +1,69 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelBooking;
internal sealed class CancelBookingCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CancelBookingCommand, OperationResult<CancellationResultDto>>
{
public async ValueTask<OperationResult<CancellationResultDto>> Handle(CancelBookingCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CancellationResultDto>.UnauthorizedResult("Not authenticated.");
var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<CancellationResultDto>.NotFoundResult("Booking not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isOwningCustomer = customerId == booking.CustomerId;
var isAssignedNurse = nurseId == booking.NurseId;
if (!isAdmin && !isOwningCustomer && !isAssignedNurse)
return OperationResult<CancellationResultDto>.NotFoundResult("Booking not found.");
if (!booking.CanTransitionTo(BookingStatus.Cancelled))
return OperationResult<CancellationResultDto>.ConflictResult($"A booking in '{booking.Status}' can no longer be cancelled.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var actor = CancellationHelper.ResolveActor(isAdmin, isAssignedNurse && !isOwningCustomer);
var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(actor, cancellationToken);
var hoursBefore = CancellationHelper.HoursBeforeStart(booking.ScheduledDate, booking.ScheduledTimeStart, now);
var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore);
if (policy is null)
return OperationResult<CancellationResultDto>.FailureResult("No cancellation policy applies to this booking.");
// Only un-started (still scheduled) sessions are refundable; started/completed ones are not.
var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount);
long refundableBase = 0;
foreach (var session in booking.Sessions)
{
if (session.Status != BookingSessionStatus.Scheduled)
continue;
refundableBase += grossShares[session.SessionIndex - 1];
session.TransitionTo(BookingSessionStatus.Cancelled);
}
var refundable = CancellationHelper.Refundable(refundableBase, policy.RefundPercentage);
booking.TransitionTo(BookingStatus.Cancelled, now, actor, request.Reason);
booking.RecordCancellationSnapshot(policy.Code, policy.RefundPercentage, refundable);
await unitOfWork.CommitAsync();
return OperationResult<CancellationResultDto>.SuccessResult(new CancellationResultDto(
booking.Id, null, booking.Status, policy.Code, policy.RefundPercentage, refundable.ToString()));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.CancelBooking;
public sealed class CancelBookingCommandValidator : AbstractValidator<CancelBookingCommand>
{
public CancelBookingCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelBooking;
/// <summary>Cancels a whole booking: resolves the applicable cancellation policy by lead time + actor,
/// freezes its <c>code</c> + <c>refund_percentage</c> onto the booking, marks every un-started session
/// cancelled, and moves the booking to <c>cancelled</c>. Only un-started (still <c>scheduled</c>) sessions are
/// refundable. No refund ledger is posted — that is b11. The booking id comes from the route.</summary>
public record CancelBookingCommand(string Reason, long BookingId = 0)
: IRequest<OperationResult<CancellationResultDto>>;
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelSession;
internal sealed class CancelSessionCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CancelSessionCommand, OperationResult<CancellationResultDto>>
{
public async ValueTask<OperationResult<CancellationResultDto>> Handle(CancelSessionCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CancellationResultDto>.UnauthorizedResult("Not authenticated.");
var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken);
var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId);
if (booking is null || session is null)
return OperationResult<CancellationResultDto>.NotFoundResult("Session not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isOwningCustomer = customerId == booking.CustomerId;
var isAssignedNurse = nurseId == booking.NurseId;
if (!isAdmin && !isOwningCustomer && !isAssignedNurse)
return OperationResult<CancellationResultDto>.NotFoundResult("Session not found.");
// Only an un-started session can be cancelled (and refunded); a started/finished visit cannot.
if (session.Status != BookingSessionStatus.Scheduled)
return OperationResult<CancellationResultDto>.ConflictResult("Only an un-started session can be cancelled.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var actor = CancellationHelper.ResolveActor(isAdmin, isAssignedNurse && !isOwningCustomer);
var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(actor, cancellationToken);
var hoursBefore = CancellationHelper.HoursBeforeStart(session.ScheduledDate, session.ScheduledTimeStart, now);
var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore);
if (policy is null)
return OperationResult<CancellationResultDto>.FailureResult("No cancellation policy applies to this session.");
var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount);
var refundableBase = grossShares[session.SessionIndex - 1];
var refundable = CancellationHelper.Refundable(refundableBase, policy.RefundPercentage);
session.TransitionTo(BookingSessionStatus.Cancelled);
// The booking keeps the most recent cancellation snapshot; the typed per-event record lands in b11.
booking.RecordCancellationSnapshot(policy.Code, policy.RefundPercentage, refundable);
await unitOfWork.CommitAsync();
return OperationResult<CancellationResultDto>.SuccessResult(new CancellationResultDto(
booking.Id, session.Id, booking.Status, policy.Code, policy.RefundPercentage, refundable.ToString()));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.CancelSession;
public sealed class CancelSessionCommandValidator : AbstractValidator<CancelSessionCommand>
{
public CancelSessionCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CancelSession;
/// <summary>Cancels a single un-started session mid-engagement: resolves + snapshots the applicable policy,
/// computes the refundable amount for that session's share of gross, and marks the session <c>cancelled</c>.
/// A session already <c>in_progress</c>/<c>completed</c> is not refundable. No refund ledger is posted (b11).
/// The session id comes from the route.</summary>
public record CancelSessionCommand(string Reason, long SessionId = 0)
: IRequest<OperationResult<CancellationResultDto>>;
@@ -0,0 +1,154 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckInVisit;
internal sealed class CheckInVisitCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
IGeocoder geocoder,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications)
: IRequestHandler<CheckInVisitCommand, OperationResult<VisitVerificationDto>>
{
private static readonly JsonSerializerOptions SnapshotJson = new() { PropertyNameCaseInsensitive = true };
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(CheckInVisitCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VisitVerificationDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VisitVerificationDto>.ForbiddenResult("Only a nurse can check in.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<VisitVerificationDto>.ForbiddenResult("No nurse profile exists yet.");
var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken);
var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId);
if (booking is null || session is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (booking.NurseId != nid)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (booking.Status is not (BookingStatus.Confirmed or BookingStatus.InProgress))
return OperationResult<VisitVerificationDto>.ConflictResult("This booking is not in a state where a visit can start.");
if (!session.CanTransitionTo(BookingSessionStatus.InProgress))
return OperationResult<VisitVerificationDto>.ConflictResult("This session cannot be checked in.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var verification = session.Verification ?? new VisitVerification { BookingSessionId = session.Id };
session.Verification = verification;
verification.CheckInAt = now;
verification.CheckInLat = request.Latitude;
verification.CheckInLng = request.Longitude;
// Advisory address match — computed only when GPS is present. GPS-denied still checks in (flagged null).
bool mismatch = false;
if (request.Latitude is { } lat && request.Longitude is { } lng)
{
var addressPoint = await ResolveAddressPointAsync(booking.AddressSnapshotJson, cancellationToken);
if (addressPoint is { } point)
{
var toleranceMeters = await platformConfig.GetConfig<int>("evv_location_tolerance_meters", cancellationToken);
var distance = GeoDistance.HaversineMeters((double)point.Lat, (double)point.Lng, (double)lat, (double)lng);
verification.CheckInDistanceMeters = (decimal)Math.Round(distance, 2);
verification.CheckInAddressMatch = distance <= toleranceMeters;
mismatch = distance > toleranceMeters;
}
}
verification.MarkCheckedIn();
session.TransitionTo(BookingSessionStatus.InProgress);
// First relevant check-in moves the booking to in_progress; subsequent check-ins leave it as-is.
if (booking.CanTransitionTo(BookingStatus.InProgress))
booking.TransitionTo(BookingStatus.InProgress, now);
await unitOfWork.CommitAsync();
// A mismatch is advisory: raise an admin alert + notify the family — never block, never cancel.
if (mismatch)
{
await supportAlerts.RaiseAsync(
SupportAlertType.EvvLocationMismatch,
entityType: "booking_session",
entityId: session.Id.ToString(),
severity: SupportAlertSeverity.Medium,
bookingId: booking.Id,
cancellationToken: cancellationToken);
var participants = await unitOfWork.BookingRepository.GetParticipantsAsync(booking.Id, cancellationToken);
if (participants is not null)
await notifications.DispatchAsync(
new Notification(
participants.CustomerUserId,
"evv_location_mismatch",
"Check-in location flagged",
"The nurse checked in outside the expected area. Our team will review it — the visit continues normally.",
JsonSerializer.Serialize(new { booking_id = booking.Id, session_id = session.Id })),
cancellationToken);
}
var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(session.Id, cancellationToken);
return OperationResult<VisitVerificationDto>.SuccessResult(gate!.Evv!);
}
// The booking address is frozen in the (decrypted) snapshot; reuse IGeocoder to resolve its coordinates,
// falling back to the coordinates already frozen into the snapshot. Returns null when neither is available.
private async Task<(decimal Lat, decimal Lng)?> ResolveAddressPointAsync(string addressSnapshotJson, CancellationToken cancellationToken)
{
AddressSnapshotJsonModel? snapshot;
try
{
snapshot = JsonSerializer.Deserialize<AddressSnapshotJsonModel>(addressSnapshotJson, SnapshotJson);
}
catch (JsonException)
{
snapshot = null;
}
if (snapshot is null)
return null;
var geo = await geocoder.GeocodeAsync(
snapshot.AddressLine ?? string.Empty,
snapshot.CityNameEn ?? string.Empty,
snapshot.DistrictNameEn,
cancellationToken);
if (geo.Latitude is { } gLat && geo.Longitude is { } gLng)
return (gLat, gLng);
if (snapshot.Latitude is { } sLat && snapshot.Longitude is { } sLng)
return (sLat, sLng);
return null;
}
private sealed record AddressSnapshotJsonModel(
string? AddressLine,
string? CityNameEn,
string? DistrictNameEn,
decimal? Latitude,
decimal? Longitude);
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckInVisit;
/// <summary>The assigned nurse clocks in: captures GPS + timestamp into the session's EVV record, computes
/// the advisory address-match against <c>evv_location_tolerance_meters</c>, and moves the session (and the
/// booking) to <c>in_progress</c>. A location mismatch raises an admin alert + notifies — it never blocks.
/// GPS-denied (null coordinates) still checks in, flagged. The session id comes from the route.</summary>
public record CheckInVisitCommand(decimal? Latitude, decimal? Longitude, long SessionId = 0)
: IRequest<OperationResult<VisitVerificationDto>>;
@@ -0,0 +1,77 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckOutVisit;
internal sealed class CheckOutVisitCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CheckOutVisitCommand, OperationResult<VisitVerificationDto>>
{
// A session is "settled" for booking-completion purposes when it can no longer become in_progress.
private static readonly string[] TerminalSessionStatuses =
[BookingSessionStatus.Completed, BookingSessionStatus.Missed, BookingSessionStatus.Cancelled];
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(CheckOutVisitCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VisitVerificationDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VisitVerificationDto>.ForbiddenResult("Only a nurse can check out.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<VisitVerificationDto>.ForbiddenResult("No nurse profile exists yet.");
var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken);
var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId);
if (booking is null || session is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (booking.NurseId != nid)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
var verification = session.Verification;
if (verification is null || verification.Status != VisitVerificationStatus.CheckedIn)
return OperationResult<VisitVerificationDto>.FailureResult("Check-out must follow an open check-in.");
if (!session.CanTransitionTo(BookingSessionStatus.Completed))
return OperationResult<VisitVerificationDto>.ConflictResult("This session cannot be checked out.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var disputeWindowHours = await platformConfig.GetConfig<int>("dispute_window_hours", cancellationToken);
verification.CheckOutAt = now;
verification.CheckOutLat = request.Latitude;
verification.CheckOutLng = request.Longitude;
verification.MarkCompleted();
session.TransitionTo(BookingSessionStatus.Completed);
// Per-session payout gate — the ONLY thing that makes this session payout-eligible (b13). Never the
// completed status alone.
session.SetPayoutEligible(now.AddHours(disputeWindowHours));
// When every session is settled, the booking completes and its dispute window opens.
var allSettled = booking.Sessions.All(s => TerminalSessionStatuses.Contains(s.Status));
if (allSettled && booking.CanTransitionTo(BookingStatus.Completed))
{
booking.TransitionTo(BookingStatus.Completed, now);
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
}
await unitOfWork.CommitAsync();
var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(session.Id, cancellationToken);
return OperationResult<VisitVerificationDto>.SuccessResult(gate!.Evv!);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.CheckOutVisit;
/// <summary>The assigned nurse clocks out — must follow an open check-in. Captures GPS + timestamp, completes
/// the session's EVV, sets the session's per-session payout-eligibility window, and — when every session of
/// the booking is terminal — completes the booking and sets its dispute window. The session id comes from the
/// route.</summary>
public record CheckOutVisitCommand(decimal? Latitude, decimal? Longitude, long SessionId = 0)
: IRequest<OperationResult<VisitVerificationDto>>;
@@ -0,0 +1,164 @@
#nullable enable
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
// The singular b8 namespace Baya.Application.Features.Booking shadows the entity type name `Booking` when
// referenced unqualified from this (plural) Features.Bookings area — alias it to disambiguate.
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
internal sealed class ConvertRequestToBookingCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
IPaymentCaptureSimulator paymentCapture,
IVariantSnapshotSerializer variantSnapshotSerializer,
INotificationDispatcher notifications)
: IRequestHandler<ConvertRequestToBookingCommand, OperationResult<BookingDetailDto>>
{
public async ValueTask<OperationResult<BookingDetailDto>> Handle(ConvertRequestToBookingCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<BookingDetailDto>.UnauthorizedResult("Not authenticated.");
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(request.BookingRequestId, cancellationToken);
if (source is null)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking request not found.");
// The payer (owning customer) or an admin may trigger the (mock) capture. Any other caller must not
// even learn the request exists.
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var callerCustomerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (!isAdmin && callerCustomerId != source.CustomerId)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking request not found.");
// Idempotency: the UNIQUE booking_request_id means a replay can't create a second booking — return
// the one already created.
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(source.RequestId, cancellationToken);
if (existingId is { } existing)
{
var existingDetail = await unitOfWork.BookingRepository.GetDetailAsync(existing, cancellationToken);
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(existingDetail!, includeAddress: true));
}
if (source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return OperationResult<BookingDetailDto>.ConflictResult("This request is not awaiting payment and cannot be converted.");
// A booking exists ONLY on a successful capture — a failed capture creates nothing.
var capture = await paymentCapture.ConfirmCaptureAsync(source.RequestId, cancellationToken);
if (!capture.Succeeded)
return OperationResult<BookingDetailDto>.FailureResult("Payment capture failed; no booking was created.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
// session_count comes from the variant (a single visit is 1); gross = price × sessions.
var sessionCount = source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1;
var gross = source.VariantSnapshot.Price * sessionCount;
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var (commission, payout) = BookingAmounts.Split(gross, rate);
var booking = new BookingEntity
{
BookingRequestId = source.RequestId,
CustomerId = source.CustomerId,
NurseId = source.NurseId,
PatientId = source.PatientId,
VariantId = source.VariantId,
CustomerAddressId = source.CustomerAddressId,
VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot),
AddressSnapshotJson = SerializeAddress(source.AddressSnapshot),
GrossPriceIrr = gross,
BalinyaarCommissionIrr = commission,
PlatformFeeRate = rate,
NursePayoutAmount = payout,
PspFeeAmount = capture.PspFeeAmount,
SessionCount = (short)sessionCount,
ScheduledDate = source.RequestedDate,
ScheduledTimeStart = source.RequestedTimeStart,
ScheduledTimeEnd = source.RequestedTimeEnd
};
// Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount.
var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount);
for (var i = 0; i < sessionCount; i++)
{
booking.Sessions.Add(new BookingSession
{
SessionIndex = i + 1,
ScheduledDate = source.RequestedDate,
ScheduledTimeStart = source.RequestedTimeStart,
ScheduledTimeEnd = source.RequestedTimeEnd,
VisitPayoutAmount = visitPayouts[i]
});
}
booking.TransitionTo(BookingStatus.Confirmed, now);
// Flip the request → converted in the same unit of work. Re-check the tracked state so a racing
// cancel/expiry that already moved it is a clean conflict, not a double conversion.
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
return OperationResult<BookingDetailDto>.ConflictResult("This request can no longer be converted.");
trackedRequest.MarkConverted();
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
await unitOfWork.CommitAsync();
await notifications.DispatchAsync(
new Notification(
source.CustomerUserId,
"booking_confirmed",
"Booking confirmed",
"Your payment was captured and your booking is confirmed.",
JsonSerializer.Serialize(new { booking_id = booking.Id })),
cancellationToken);
await notifications.DispatchAsync(
new Notification(
source.NurseUserId,
"booking_confirmed_nurse",
"New confirmed booking",
"A booking has been confirmed and paid. The care instructions and schedule are now available.",
JsonSerializer.Serialize(new { booking_id = booking.Id })),
cancellationToken);
var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken);
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true));
}
// Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant
// snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe.
private static readonly JsonSerializerOptions AddressJson = new()
{
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};
private static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new
{
addressId = a.AddressId,
title = a.Title,
cityId = a.CityId,
cityNameFa = a.CityNameFa,
cityNameEn = a.CityNameEn,
districtId = a.DistrictId,
districtNameFa = a.DistrictNameFa,
districtNameEn = a.DistrictNameEn,
addressLine = a.AddressLine,
postalCode = a.PostalCode,
recipientName = a.RecipientName,
recipientPhone = a.RecipientPhone,
latitude = a.Latitude,
longitude = a.Longitude
}, AddressJson);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
public sealed class ConvertRequestToBookingCommandValidator : AbstractValidator<ConvertRequestToBookingCommand>
{
public ConvertRequestToBookingCommandValidator()
{
RuleFor(x => x.BookingRequestId).GreaterThan(0);
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
/// <summary>
/// The conversion engine — <b>invoked by payment capture</b> (mocked via <c>IPaymentCaptureSimulator</c>
/// now, real card capture in b10). It loads an <c>accepted_awaiting_payment</c> booking request, verifies a
/// successful capture, then creates the 1:1 <c>bookings</c> row (<c>pending_payment → confirmed</c>), writes
/// the variant + encrypted address snapshots, computes the three amounts, generates ≥ 1 reconciling session,
/// and flips the request to <c>converted</c> — all in one unit of work. Idempotent: the unique
/// <c>booking_request_id</c> means a replay returns the existing booking rather than creating a second one.
/// </summary>
public record ConvertRequestToBookingCommand(long BookingRequestId) : IRequest<OperationResult<BookingDetailDto>>;
@@ -0,0 +1,76 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.SupportAlerts;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
internal sealed class DetectNoShowSessionsCommandHandler(
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications)
: IRequestHandler<DetectNoShowSessionsCommand, OperationResult<NoShowSweepResult>>
{
private const int BatchSize = 200;
public async ValueTask<OperationResult<NoShowSweepResult>> Handle(DetectNoShowSessionsCommand request, CancellationToken cancellationToken)
{
var now = dateTimeProvider.UtcNow.UtcDateTime;
var thresholdMinutes = await platformConfig.GetConfig<int>("no_show_threshold_minutes", cancellationToken);
var today = DateOnly.FromDateTime(now);
var candidates = await unitOfWork.BookingRepository.GetNoShowCandidatesAsync(today, BatchSize, cancellationToken);
var missed = new List<BookingSession>();
foreach (var session in candidates)
{
var start = session.ScheduledDate.ToDateTime(session.ScheduledTimeStart, DateTimeKind.Utc);
if (start.AddMinutes(thresholdMinutes) > now)
continue;
if (!session.CanTransitionTo(BookingSessionStatus.Missed))
continue;
session.TransitionTo(BookingSessionStatus.Missed);
missed.Add(session);
}
if (missed.Count == 0)
return OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(0));
await unitOfWork.CommitAsync();
foreach (var session in missed)
{
await supportAlerts.RaiseAsync(
SupportAlertType.EvvNoShow,
entityType: "booking_session",
entityId: session.Id.ToString(),
severity: SupportAlertSeverity.High,
bookingId: session.BookingId,
cancellationToken: cancellationToken);
var participants = await unitOfWork.BookingRepository.GetParticipantsAsync(session.BookingId, cancellationToken);
if (participants is not null)
await notifications.DispatchAsync(
new Notification(
participants.CustomerUserId,
"evv_no_show",
"Nurse did not check in",
"The nurse did not check in for a scheduled visit. Our team has been alerted.",
JsonSerializer.Serialize(new { booking_id = session.BookingId, session_id = session.Id })),
cancellationToken);
}
return OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(missed.Count));
}
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
/// <summary>The no-show sweep unit of work: any session still <c>scheduled</c> past
/// <c>scheduled_start + no_show_threshold_minutes</c> with no check-in is marked <c>missed</c>, a
/// <c>no_show</c> support alert is raised, and the family is notified. Bounded + idempotent. The recurring
/// scheduler is DEFERRED — this is the command the cron will call, reachable now via an admin/test trigger.</summary>
public record DetectNoShowSessionsCommand : IRequest<OperationResult<NoShowSweepResult>>;
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
internal sealed class SubmitCareInstructionsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<SubmitCareInstructionsCommand, OperationResult<CareInstructionsDto>>
{
private static readonly string[] WritableStatuses =
[
BookingStatus.Confirmed, BookingStatus.InProgress,
BookingStatus.Completed, BookingStatus.Disputed, BookingStatus.Closed
];
public async ValueTask<OperationResult<CareInstructionsDto>> Handle(SubmitCareInstructionsCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CareInstructionsDto>.UnauthorizedResult("Not authenticated.");
var booking = await unitOfWork.BookingRepository.GetTrackedWithCareAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<CareInstructionsDto>.NotFoundResult("Booking not found.");
// Author = the owning customer, or an admin acting on their behalf.
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (!isAdmin && customerId != booking.CustomerId)
return OperationResult<CareInstructionsDto>.NotFoundResult("Booking not found.");
if (!WritableStatuses.Contains(booking.Status))
return OperationResult<CareInstructionsDto>.ConflictResult("Care instructions can only be added once the booking is confirmed.");
var care = booking.CareInstructions;
if (care is null)
{
care = new BookingCareInstruction { BookingId = booking.Id };
booking.CareInstructions = care;
}
care.CurrentConditions = request.CurrentConditions;
care.Medications = request.Medications;
care.Allergies = request.Allergies;
care.SpecialInstructions = request.SpecialInstructions;
care.EmergencyContactName = request.EmergencyContactName;
care.EmergencyContactPhone = request.EmergencyContactPhone;
await unitOfWork.CommitAsync();
return OperationResult<CareInstructionsDto>.SuccessResult(new CareInstructionsDto(
booking.Id,
care.CurrentConditions,
care.Medications,
care.Allergies,
care.SpecialInstructions,
care.EmergencyContactName,
care.EmergencyContactPhone));
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
public sealed class SubmitCareInstructionsCommandValidator : AbstractValidator<SubmitCareInstructionsCommand>
{
public SubmitCareInstructionsCommandValidator()
{
RuleFor(x => x.CurrentConditions).MaximumLength(2000);
RuleFor(x => x.Medications).MaximumLength(2000);
RuleFor(x => x.Allergies).MaximumLength(2000);
RuleFor(x => x.SpecialInstructions).MaximumLength(2000);
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
RuleFor(x => x.EmergencyContactPhone).MaximumLength(30);
// The booking id is route-supplied, so it is not validated in the body.
}
}
@@ -0,0 +1,17 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions;
/// <summary>Writes/updates the 1:1 encrypted <c>booking_care_instructions</c> for a <b>confirmed</b> booking.
/// Customer-authored (or admin). The booking id comes from the route, never the body.</summary>
public record SubmitCareInstructionsCommand(
string? CurrentConditions,
string? Medications,
string? Allergies,
string? SpecialInstructions,
string? EmergencyContactName,
string? EmergencyContactPhone,
long BookingId = 0) : IRequest<OperationResult<CareInstructionsDto>>;
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
internal sealed class TransitionBookingStatusCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<TransitionBookingStatusCommand, OperationResult<BookingDetailDto>>
{
public async ValueTask<OperationResult<BookingDetailDto>> Handle(TransitionBookingStatusCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } _)
return OperationResult<BookingDetailDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Any(BookingRoles.Admin.Contains) != true)
return OperationResult<BookingDetailDto>.ForbiddenResult("Only an admin can drive an explicit booking transition.");
var target = request.TargetStatus;
if (target == BookingStatus.Cancelled)
return OperationResult<BookingDetailDto>.FailureResult("Use the cancel endpoint so the cancellation policy is resolved and snapshotted.");
var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking not found.");
if (!booking.CanTransitionTo(target))
return OperationResult<BookingDetailDto>.ConflictResult($"A booking in '{booking.Status}' cannot transition to '{target}'.");
// No transition may contradict EVV/session state.
if (target == BookingStatus.InProgress &&
!booking.Sessions.Any(s => s.Status == BookingSessionStatus.InProgress))
return OperationResult<BookingDetailDto>.ConflictResult("Cannot move to in_progress with no session checked in.");
if (target == BookingStatus.Completed &&
booking.Sessions.Any(s => s.Status is BookingSessionStatus.Scheduled or BookingSessionStatus.InProgress))
return OperationResult<BookingDetailDto>.ConflictResult("Cannot complete a booking while a session is still scheduled or in progress.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
booking.TransitionTo(target, now, reason: request.Reason);
// An admin-driven completion still opens the dispute window (the single payout-eligibility trigger).
if (target == BookingStatus.Completed)
{
var disputeWindowHours = await platformConfig.GetConfig<int>("dispute_window_hours", cancellationToken);
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
}
await unitOfWork.CommitAsync();
var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken);
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
public sealed class TransitionBookingStatusCommandValidator : AbstractValidator<TransitionBookingStatusCommand>
{
public TransitionBookingStatusCommandValidator()
{
RuleFor(x => x.TargetStatus).NotEmpty();
RuleFor(x => x.Reason).MaximumLength(500);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
/// <summary>Applies an admin/dispute status change to a booking — only if allowed by the transition table
/// <b>and</b> consistent with EVV/session state (e.g. you cannot move to <c>in_progress</c> with no session
/// checked in, nor to <c>completed</c> while a session is still live). Cancellation goes through the cancel
/// endpoint (which snapshots the policy), not here. The booking id comes from the route.</summary>
public record TransitionBookingStatusCommand(string TargetStatus, string? Reason = null, long BookingId = 0)
: IRequest<OperationResult<BookingDetailDto>>;
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
// Admin-only access is enforced by the controller policy.
internal sealed class UpsertCancellationPolicyCommandHandler(IUnitOfWork unitOfWork)
: IRequestHandler<UpsertCancellationPolicyCommand, OperationResult<CancellationPolicyDto>>
{
public async ValueTask<OperationResult<CancellationPolicyDto>> Handle(UpsertCancellationPolicyCommand request, CancellationToken cancellationToken)
{
var policy = await unitOfWork.CancellationPolicyRepository.GetTrackedByCodeAsync(request.Code, cancellationToken);
if (policy is null)
{
policy = new CancellationPolicy { Code = request.Code };
await unitOfWork.CancellationPolicyRepository.AddAsync(policy, cancellationToken);
}
policy.AppliesTo = request.AppliesTo;
policy.HoursBeforeStartMin = request.HoursBeforeStartMin;
policy.HoursBeforeStartMax = request.HoursBeforeStartMax;
policy.RefundPercentage = request.RefundPercentage;
policy.FeeAmountIrr = request.FeeAmountIrr;
policy.FeeRate = request.FeeRate;
policy.IsActive = request.IsActive;
await unitOfWork.CommitAsync();
return OperationResult<CancellationPolicyDto>.SuccessResult(new CancellationPolicyDto(
policy.Id, policy.Code, policy.AppliesTo, policy.HoursBeforeStartMin, policy.HoursBeforeStartMax,
policy.RefundPercentage, policy.FeeAmountIrr.ToString(), policy.FeeRate, policy.IsActive));
}
}
@@ -0,0 +1,25 @@
using Baya.Domain.Entities.Booking;
using FluentValidation;
namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
public sealed class UpsertCancellationPolicyCommandValidator : AbstractValidator<UpsertCancellationPolicyCommand>
{
public UpsertCancellationPolicyCommandValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.AppliesTo)
.NotEmpty()
.Must(CancellationActor.IsValid)
.WithMessage("applies_to must be one of: customer, nurse, admin.");
RuleFor(x => x.RefundPercentage).InclusiveBetween(0m, 100m);
RuleFor(x => x.FeeAmountIrr).GreaterThanOrEqualTo(0);
RuleFor(x => x.FeeRate).InclusiveBetween(0m, 1m).When(x => x.FeeRate.HasValue);
RuleFor(x => x)
.Must(x => x.HoursBeforeStartMin is null || x.HoursBeforeStartMax is null || x.HoursBeforeStartMin < x.HoursBeforeStartMax)
.WithMessage("hours_before_start_min must be less than hours_before_start_max.");
}
}
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy;
/// <summary>Admin create/update of a cancellation tier, keyed by unique <c>code</c>. Editing a policy never
/// mutates an already-snapshotted cancellation (past cancellations froze the code + percentage). The fee is
/// a flat IRR amount plus an optional fraction; a tier uses whichever is non-zero.</summary>
public record UpsertCancellationPolicyCommand(
string Code,
string AppliesTo,
int? HoursBeforeStartMin,
int? HoursBeforeStartMax,
decimal RefundPercentage,
long FeeAmountIrr,
decimal? FeeRate,
bool IsActive) : IRequest<OperationResult<CancellationPolicyDto>>;
@@ -0,0 +1,38 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetBookingDetail;
internal sealed class GetBookingDetailQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetBookingDetailQuery, OperationResult<BookingDetailDto>>
{
public async ValueTask<OperationResult<BookingDetailDto>> Handle(GetBookingDetailQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<BookingDetailDto>.UnauthorizedResult("Not authenticated.");
var detail = await unitOfWork.BookingRepository.GetDetailAsync(request.Id, cancellationToken);
if (detail is null)
return OperationResult<BookingDetailDto>.NotFoundResult("Booking not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId == detail.CustomerId)
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: true));
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId == detail.NurseId)
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: false));
if (isAdmin)
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: true));
// Neither party nor admin — do not leak that the booking exists.
return OperationResult<BookingDetailDto>.NotFoundResult("Booking not found.");
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetBookingDetail;
/// <summary>Booking header + money summary + sessions + status timeline, tenancy-scoped: the customer sees
/// their own bookings, the nurse their assigned bookings, admin all. Never cross-tenant; the nurse view omits
/// the address snapshot and no query ever surfaces care-instruction clinical fields.</summary>
public record GetBookingDetailQuery(long Id) : IRequest<OperationResult<BookingDetailDto>>;
@@ -0,0 +1,45 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetCareInstructions;
internal sealed class GetCareInstructionsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetCareInstructionsQuery, OperationResult<CareInstructionsDto>>
{
// The clinical fields are visible only once the booking is confirmed and while it is a live/closed
// engagement — never pre-payment, never on a cancelled booking.
private static readonly string[] DisclosableStatuses =
[
BookingStatus.Confirmed, BookingStatus.InProgress,
BookingStatus.Completed, BookingStatus.Disputed, BookingStatus.Closed
];
public async ValueTask<OperationResult<CareInstructionsDto>> Handle(GetCareInstructionsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CareInstructionsDto>.UnauthorizedResult("Not authenticated.");
var gate = await unitOfWork.BookingRepository.GetCareInstructionsGateAsync(request.BookingId, cancellationToken);
if (gate is null)
return OperationResult<CareInstructionsDto>.NotFoundResult("Booking not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isAssignedNurse = nurseId == gate.NurseId;
// Two-stage disclosure: only the assigned nurse or an admin, only post-confirmation. Any other caller
// (the customer, an unassigned nurse, or a pre-confirmation booking) must not learn anything.
if ((!isAssignedNurse && !isAdmin) || !DisclosableStatuses.Contains(gate.BookingStatus))
return OperationResult<CareInstructionsDto>.NotFoundResult("Care instructions not found.");
if (gate.Instructions is null)
return OperationResult<CareInstructionsDto>.NotFoundResult("No care instructions have been added for this booking yet.");
return OperationResult<CareInstructionsDto>.SuccessResult(gate.Instructions);
}
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetCareInstructions;
/// <summary>The <b>gated</b> stage-2 clinical read. Decrypts and returns the care instructions <b>only</b> to
/// (a) the assigned nurse of that booking and (b) admin, and <b>only</b> post-confirmation. Any other caller
/// — the customer, an unassigned nurse, or a pre-confirmation booking — gets a clean not-found. This is the
/// two-stage disclosure boundary; it must never leak.</summary>
public record GetCareInstructionsQuery(long BookingId) : IRequest<OperationResult<CareInstructionsDto>>;
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetVisitVerification;
internal sealed class GetVisitVerificationQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetVisitVerificationQuery, OperationResult<VisitVerificationDto>>
{
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(GetVisitVerificationQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VisitVerificationDto>.UnauthorizedResult("Not authenticated.");
var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(request.SessionId, cancellationToken);
if (gate is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
// Raw GPS detail is gated to the owning nurse + admin only.
if (nurseId != gate.NurseId && !isAdmin)
return OperationResult<VisitVerificationDto>.NotFoundResult("Session not found.");
if (gate.Evv is null)
return OperationResult<VisitVerificationDto>.NotFoundResult("No EVV record exists for this session yet.");
return OperationResult<VisitVerificationDto>.SuccessResult(gate.Evv);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.GetVisitVerification;
/// <summary>Per-session EVV detail. Raw GPS is gated to the owning nurse + admin only; any other caller gets
/// a clean not-found.</summary>
public record GetVisitVerificationQuery(long SessionId) : IRequest<OperationResult<VisitVerificationDto>>;
@@ -0,0 +1,22 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListAdminEvv;
// Admin-only access is enforced by the controller policy; this handler assumes an admin caller.
internal sealed class ListAdminEvvQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListAdminEvvQuery, OperationResult<PagedResult<AdminEvvItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<AdminEvvItemDto>>> Handle(ListAdminEvvQuery request, CancellationToken cancellationToken)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var type = request.Type == "no_show" ? "no_show" : "mismatch";
var result = await unitOfWork.BookingRepository.ListAdminEvvAsync(type, page, pageSize, cancellationToken);
return OperationResult<PagedResult<AdminEvvItemDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListAdminEvv;
/// <summary>The admin EVV-review queue: <c>type=mismatch</c> (advisory location mismatches) or
/// <c>type=no_show</c> (missed sessions). Projected + paginated; behind the admin policy.</summary>
public record ListAdminEvvQuery(string Type = "mismatch", int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<AdminEvvItemDto>>>;
@@ -0,0 +1,52 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListBookings;
internal sealed class ListBookingsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListBookingsQuery, OperationResult<PagedResult<BookingListItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<BookingListItemDto>>> Handle(ListBookingsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<BookingListItemDto>>.UnauthorizedResult("Not authenticated.");
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var role = request.Role?.ToLowerInvariant();
if (role == "all")
{
if (currentUser.Roles?.Any(BookingRoles.Admin.Contains) != true)
return OperationResult<PagedResult<BookingListItemDto>>.ForbiddenResult("Only an admin can list all bookings.");
var all = await unitOfWork.BookingRepository.ListAllAsync(request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(all);
}
if (role == "nurse")
{
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(Empty(page, pageSize));
var nurseList = await unitOfWork.BookingRepository.ListForNurseAsync(nid, request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(nurseList);
}
// Default: the customer's own bookings.
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(Empty(page, pageSize));
var list = await unitOfWork.BookingRepository.ListForCustomerAsync(cid, request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingListItemDto>>.SuccessResult(list);
}
private static PagedResult<BookingListItemDto> Empty(int page, int pageSize)
=> new([], 0, page, pageSize);
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListBookings;
/// <summary>The role-scoped "My bookings" list. <c>role=customer</c> (default) or <c>role=nurse</c> scopes
/// to the caller; <c>role=all</c> is the admin-only list-everything variant. Status-filterable, projected,
/// paginated.</summary>
public record ListBookingsQuery(string? Role = null, string? Status = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<BookingListItemDto>>>;
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListCancellationPolicies;
// Admin-only access is enforced by the controller policy.
internal sealed class ListCancellationPoliciesQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListCancellationPoliciesQuery, OperationResult<IReadOnlyList<CancellationPolicyDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<CancellationPolicyDto>>> Handle(ListCancellationPoliciesQuery request, CancellationToken cancellationToken)
{
var policies = await unitOfWork.CancellationPolicyRepository.ListAsync(cancellationToken);
return OperationResult<IReadOnlyList<CancellationPolicyDto>>.SuccessResult(policies);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListCancellationPolicies;
/// <summary>All cancellation tiers (active + inactive) for the admin management screen.</summary>
public record ListCancellationPoliciesQuery : IRequest<OperationResult<IReadOnlyList<CancellationPolicyDto>>>;
@@ -0,0 +1,32 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListSessionsForNurseQuery, OperationResult<PagedResult<BookingSessionListItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<BookingSessionListItemDto>>> Handle(ListSessionsForNurseQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<BookingSessionListItemDto>>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<PagedResult<BookingSessionListItemDto>>.ForbiddenResult("Only a nurse can view their sessions.");
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(new PagedResult<BookingSessionListItemDto>([], 0, page, pageSize));
var result = await unitOfWork.BookingRepository.ListSessionsForNurseAsync(nid, request.Date, page, pageSize, cancellationToken);
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
/// <summary>The signed-in nurse's sessions for a day (today's visits by default), each with its check-in/out
/// CTA state. Tenancy-scoped to the nurse via <c>ICurrentUser</c>, projected + paginated.</summary>
public record ListSessionsForNurseQuery(DateOnly? Date = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<BookingSessionListItemDto>>>;
@@ -0,0 +1,142 @@
#nullable enable
namespace Baya.Application.Models.Booking;
/// <summary>
/// The full booking view returned by convert/detail/transition/cancel. Money crosses the wire as strings of
/// IRR-Rial digits (integer money, no floats). The <b>nurse</b> view omits the encrypted address snapshot;
/// the <b>customer/admin</b> view includes it. Care-instruction clinical fields are <b>never</b> here — they
/// live behind the gated care-instructions read.
/// </summary>
public record BookingDetailDto(
long Id,
long BookingRequestId,
string Status,
long NurseId,
string NurseName,
long PatientId,
string PatientName,
long VariantId,
string VariantSnapshotJson,
long CustomerAddressId,
string? AddressSnapshotJson,
string GrossPriceIrr,
string BalinyaarCommissionIrr,
decimal PlatformFeeRate,
string NursePayoutAmount,
string? PspFeeAmount,
short SessionCount,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
DateTime? ConfirmedAt,
DateTime? CompletedAt,
DateTime? CancelledAt,
string? CancelledBy,
string? CancellationReason,
string? CancellationPolicyCode,
decimal? CancellationRefundPercentage,
string? RefundableAmountIrr,
DateTime? DisputeWindowEndsAt,
DateTimeOffset CreatedAt,
IReadOnlyList<BookingSessionSummaryDto> Sessions);
/// <summary>Per-visit summary embedded in the booking detail — schedule, status, payout, and EVV state.</summary>
public record BookingSessionSummaryDto(
long Id,
int SessionIndex,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
string Status,
string VisitPayoutAmount,
DateTime? PayoutEligibleAt,
string EvvStatus,
DateTime? CheckInAt,
DateTime? CheckOutAt,
bool? CheckInAddressMatch);
/// <summary>The role-scoped "My bookings" list item — counterparty is the nurse (customer view) or the
/// patient (nurse view); <see cref="AmountIrr"/> is the gross (customer) or the payout (nurse).</summary>
public record BookingListItemDto(
long Id,
string Status,
string CounterpartyName,
DateOnly ScheduledDate,
short SessionCount,
string AmountIrr,
DateTime? DisputeWindowEndsAt,
DateTimeOffset CreatedAt);
/// <summary>The nurse's "today" session-list item, with the per-session check-in/out CTA state.</summary>
public record BookingSessionListItemDto(
long SessionId,
long BookingId,
int SessionIndex,
string PatientName,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
string Status,
string EvvStatus);
/// <summary>The decrypted stage-2 clinical fields — returned <b>only</b> to the assigned nurse + admin,
/// <b>only</b> post-confirmation. Never projected into a list or logged.</summary>
public record CareInstructionsDto(
long BookingId,
string? CurrentConditions,
string? Medications,
string? Allergies,
string? SpecialInstructions,
string? EmergencyContactName,
string? EmergencyContactPhone);
/// <summary>Per-session EVV detail — raw GPS gated to the owning nurse + admin.</summary>
public record VisitVerificationDto(
long Id,
long BookingSessionId,
string Status,
DateTime? CheckInAt,
decimal? CheckInLat,
decimal? CheckInLng,
DateTime? CheckOutAt,
decimal? CheckOutLat,
decimal? CheckOutLng,
bool? CheckInAddressMatch,
decimal? CheckInDistanceMeters);
/// <summary>An item in the admin EVV-review queue (location mismatch or no-show).</summary>
public record AdminEvvItemDto(
long SessionId,
long BookingId,
long NurseId,
string SessionStatus,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
DateTime? CheckInAt,
bool? CheckInAddressMatch,
decimal? CheckInDistanceMeters);
/// <summary>Admin-facing cancellation-policy row.</summary>
public record CancellationPolicyDto(
long Id,
string Code,
string AppliesTo,
int? HoursBeforeStartMin,
int? HoursBeforeStartMax,
decimal RefundPercentage,
string FeeAmountIrr,
decimal? FeeRate,
bool IsActive);
/// <summary>The outcome of a cancellation — the frozen policy snapshot + computed refundable amount. No
/// refund ledger is posted here (that is b11); this is the figure b11 will consume.</summary>
public record CancellationResultDto(
long BookingId,
long? SessionId,
string BookingStatus,
string PolicyCode,
decimal RefundPercentage,
string RefundableAmountIrr);
/// <summary>How many sessions the no-show sweep flagged (idempotent — re-running with none returns zero).</summary>
public record NoShowSweepResult(int Missed);
@@ -0,0 +1,111 @@
#nullable enable
using Baya.Application.Models.Catalog;
namespace Baya.Application.Models.Booking;
/// <summary>
/// The role-agnostic booking detail projection. Carries both parties' ids so the query handler can authorize
/// the caller and decide address-snapshot visibility, plus the decrypted address snapshot (masked for the
/// nurse). Money is the raw IRR <c>long</c>; the mapper stringifies it for the wire.
/// </summary>
public record BookingDetailProjection(
long Id,
long BookingRequestId,
string Status,
long CustomerId,
long NurseId,
string NurseName,
long PatientId,
string PatientName,
long VariantId,
string VariantSnapshotJson,
long CustomerAddressId,
string AddressSnapshotJson,
long GrossPriceIrr,
long BalinyaarCommissionIrr,
decimal PlatformFeeRate,
long NursePayoutAmount,
long? PspFeeAmount,
short SessionCount,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
DateTime? ConfirmedAt,
DateTime? CompletedAt,
DateTime? CancelledAt,
string? CancelledBy,
string? CancellationReason,
string? CancellationPolicyCode,
decimal? CancellationRefundPercentage,
long? RefundableAmountIrr,
DateTime? DisputeWindowEndsAt,
DateTimeOffset CreatedAt,
IReadOnlyList<BookingSessionProjection> Sessions);
/// <summary>Per-visit projection embedded in <see cref="BookingDetailProjection"/>.</summary>
public record BookingSessionProjection(
long Id,
int SessionIndex,
DateOnly ScheduledDate,
TimeOnly ScheduledTimeStart,
TimeOnly ScheduledTimeEnd,
string Status,
long VisitPayoutAmount,
DateTime? PayoutEligibleAt,
string? EvvStatus,
DateTime? CheckInAt,
DateTime? CheckOutAt,
bool? CheckInAddressMatch);
/// <summary>
/// Everything <c>ConvertRequestToBookingCommand</c> needs to build a booking from an
/// <c>accepted_awaiting_payment</c> request in one read: the ids + participant user ids (for notifications),
/// the engagement schedule, and the source data for the two frozen snapshots.
/// </summary>
public record BookingConversionSource(
long RequestId,
string Status,
long CustomerId,
int CustomerUserId,
long NurseId,
int NurseUserId,
long PatientId,
string PatientName,
long VariantId,
long CustomerAddressId,
DateOnly RequestedDate,
TimeOnly RequestedTimeStart,
TimeOnly RequestedTimeEnd,
VariantSnapshot VariantSnapshot,
AddressSnapshot AddressSnapshot);
/// <summary>The immutable address data frozen into <c>address_snapshot_json</c> at booking time — full,
/// decrypted, plus the geocoded coordinates the EVV distance check later reads (so EVV is independent of
/// later address edits).</summary>
public record AddressSnapshot(
long AddressId,
string Title,
long CityId,
string CityNameFa,
string CityNameEn,
long? DistrictId,
string? DistrictNameFa,
string? DistrictNameEn,
string AddressLine,
string PostalCode,
string RecipientName,
string RecipientPhone,
decimal? Latitude,
decimal? Longitude);
/// <summary>The two participant user ids for a booking — the recipients of booking notifications.</summary>
public record BookingParticipants(int CustomerUserId, int NurseUserId);
/// <summary>The authorization envelope for the gated care-instructions read: the booking's status + both
/// party ids, plus the decrypted instructions (null when none written yet). The handler enforces the
/// two-stage disclosure boundary from these facts before ever surfacing <see cref="Instructions"/>.</summary>
public record CareInstructionsGate(string BookingStatus, long NurseId, long CustomerId, CareInstructionsDto? Instructions);
/// <summary>The authorization envelope for the per-session EVV read: both party ids + the EVV detail (null
/// when no verification exists yet). Raw GPS is surfaced only to the owning nurse + admin.</summary>
public record EvvGate(long NurseId, long CustomerId, VisitVerificationDto? Evv);
@@ -0,0 +1,141 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The <b>confirmed engagement</b> — the source of truth for a service event and its money split. A
/// <see cref="Booking"/> exists <b>only</b> when the nurse accepted <i>and</i> payment was captured; it is
/// created 1:1 from an <c>accepted_awaiting_payment</c> booking request and never from an accept alone.
/// <para>
/// Money is IRR <c>BIGINT</c> only. The three amounts must always reconcile —
/// <see cref="GrossPriceIrr"/> = <see cref="BalinyaarCommissionIrr"/> + <see cref="NursePayoutAmount"/>,
/// all ≥ 0 — enforced both by a DB CHECK and by the conversion handler; <see cref="NursePayoutAmount"/> is
/// derived, never free-entered. <see cref="PlatformFeeRate"/>, <see cref="VariantSnapshotJson"/> and the
/// encrypted <see cref="AddressSnapshotJson"/> are <b>frozen at conversion</b>: later edits to the source
/// variant/address/config rows must never mutate an existing booking.
/// </para>
/// <para>
/// There is deliberately <b>no</b> "payout done" boolean — paid-ness is derived later from a
/// <c>nurse_payout_booking_links</c> row + the ledger (b13). Payout eligibility is derived from
/// <see cref="DisputeWindowEndsAt"/> passing with no open dispute, never from <see cref="Status"/> alone.
/// </para>
/// </summary>
public class Booking : BaseEntity<long>
{
/// <summary>1:1 with the request that created it (UNIQUE) — the idempotency key for conversion.</summary>
public long BookingRequestId { get; set; }
// Denormalized FKs (copied from the request) for query performance.
public long CustomerId { get; set; }
public long NurseId { get; set; }
public long PatientId { get; set; }
public long VariantId { get; set; }
public long CustomerAddressId { get; set; }
/// <summary>The licensed center / merchant-of-record. <c>partner_centers</c> is DEFERRED to b15, so the
/// FK stays nullable and unset for now.</summary>
public long? PartnerCenterId { get; set; }
/// <summary>Variant + option labels frozen at booking time (never re-resolved from the live variant).</summary>
public string VariantSnapshotJson { get; set; } = null!;
/// <summary>Full address frozen at booking time — <b>encrypted at rest</b> through the field encryptor.</summary>
public string AddressSnapshotJson { get; set; } = null!;
/// <summary>Total charged the customer (IRR).</summary>
public long GrossPriceIrr { get; set; }
/// <summary>Balinyaar's own cut (IRR) = round(<see cref="GrossPriceIrr"/> × <see cref="PlatformFeeRate"/>).</summary>
public long BalinyaarCommissionIrr { get; set; }
/// <summary>The commission rate snapshot frozen at conversion, for audit.</summary>
public decimal PlatformFeeRate { get; set; }
/// <summary>Derived: <see cref="GrossPriceIrr"/> <see cref="BalinyaarCommissionIrr"/> (IRR).</summary>
public long NursePayoutAmount { get; set; }
/// <summary>Gateway cost on this payment (IRR), for true margin. Null until a capture sets it.</summary>
public long? PspFeeAmount { get; set; }
/// <summary>1 = single visit; &gt; 1 = multi-session engagement. Always ≥ 1.</summary>
public short SessionCount { get; set; } = 1;
// Engagement-level schedule; the per-visit schedule lives on booking_sessions.
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTimeStart { get; set; }
public TimeOnly ScheduledTimeEnd { get; set; }
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the
/// allowed-transition machine.</summary>
public string Status { get; private set; } = BookingStatus.PendingPayment;
public DateTime? ConfirmedAt { get; private set; }
public DateTime? CancelledAt { get; private set; }
public string? CancellationReason { get; private set; }
/// <summary>Who cancelled — a <see cref="CancellationActor"/> code.</summary>
public string? CancelledBy { get; private set; }
/// <summary>The resolved cancellation policy <c>code</c>, <b>frozen</b> at cancel time (a later policy
/// edit must not change it). Absent a b11 refunds/cancellation-event table, the snapshot lives here.</summary>
public string? CancellationPolicyCode { get; private set; }
/// <summary>The resolved <c>refund_percentage</c> (0100), frozen at cancel time.</summary>
public decimal? CancellationRefundPercentage { get; private set; }
/// <summary>The computed refundable amount (IRR) for the un-started sessions at cancel time. The refund
/// ledger/execution is b11; this is the frozen figure b11 consumes.</summary>
public long? RefundableAmountIrr { get; private set; }
public DateTime? CompletedAt { get; private set; }
/// <summary>Set on completion = <see cref="CompletedAt"/> + config(<c>dispute_window_hours</c>, 72). The
/// <b>only</b> thing that makes a payout eligible (b13) — never <see cref="Status"/> = completed alone.</summary>
public DateTime? DisputeWindowEndsAt { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<BookingSession> Sessions { get; set; } = new List<BookingSession>();
public BookingCareInstruction? CareInstructions { get; set; }
public bool CanTransitionTo(string target) => BookingTransitions.CanTransition(Status, target);
/// <summary>
/// Applies a status change through the allowed-transition guard and stamps the matching lifecycle
/// timestamp. Callers pre-check with <see cref="CanTransitionTo"/> and return a clean conflict; reaching
/// an illegal edge here is a programming error, so it fails fast rather than overwriting a terminal state.
/// </summary>
public void TransitionTo(string target, DateTime now, string? actor = null, string? reason = null)
{
if (!BookingTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal booking transition {Status} → {target}.");
Status = target;
switch (target)
{
case BookingStatus.Confirmed:
ConfirmedAt = now;
break;
case BookingStatus.Completed:
CompletedAt = now;
break;
case BookingStatus.Cancelled:
CancelledAt = now;
CancelledBy = actor;
CancellationReason = reason;
break;
}
}
/// <summary>Freezes the resolved cancellation policy snapshot + the computed refundable amount.</summary>
public void RecordCancellationSnapshot(string policyCode, decimal refundPercentage, long refundableAmountIrr)
{
CancellationPolicyCode = policyCode;
CancellationRefundPercentage = refundPercentage;
RefundableAmountIrr = refundableAmountIrr;
}
/// <summary>Sets the dispute window on completion — the single payout-eligibility trigger for the booking.</summary>
public void SetDisputeWindow(DateTime endsAt) => DisputeWindowEndsAt = endsAt;
}
@@ -0,0 +1,44 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// Pure integer-money helpers for the booking split. All money is IRR <c>long</c> — no float survives into
/// storage. Kept here (not in a handler) so the reconciliation rules are unit-testable in isolation.
/// </summary>
public static class BookingAmounts
{
/// <summary>
/// Splits <paramref name="gross"/> into the platform commission and the derived nurse payout using the
/// snapshotted <paramref name="rate"/>. Commission is integer-rounded (half away from zero) and clamped
/// to <c>[0, gross]</c> so the invariant <c>gross = commission + payout</c> with all ≥ 0 always holds.
/// </summary>
public static (long Commission, long Payout) Split(long gross, decimal rate)
{
var commission = (long)decimal.Round(gross * rate, MidpointRounding.AwayFromZero);
if (commission < 0)
commission = 0;
if (commission > gross)
commission = gross;
return (commission, gross - commission);
}
/// <summary>
/// Distributes <paramref name="payout"/> across <paramref name="sessionCount"/> sessions so the parts
/// sum <b>exactly</b> to <paramref name="payout"/> — equal integer shares with the remainder placed on
/// the last session, so no Rial is created or lost.
/// </summary>
public static long[] SplitPayout(long payout, int sessionCount)
{
if (sessionCount < 1)
throw new ArgumentOutOfRangeException(nameof(sessionCount), "A booking always has at least one session.");
var per = payout / sessionCount;
var amounts = new long[sessionCount];
for (var i = 0; i < sessionCount; i++)
amounts[i] = per;
// Remainder of the integer division lands on the last session so Σ == payout exactly.
amounts[sessionCount - 1] += payout - per * sessionCount;
return amounts;
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// Encrypted clinical/logistical context for a booking — <b>stage 2</b> of the two-stage disclosure
/// boundary. Kept in its own 1:1 table (not on <see cref="Booking"/>) so the financial/scheduling row stays
/// clean and these fields carry stricter access control: readable <b>only post-confirmation</b> and <b>only</b>
/// by the assigned nurse + admin (never the customer, never an unassigned nurse). Every field is encrypted
/// at rest through the field encryptor and is <b>never</b> projected into a list query or logged.
/// </summary>
public class BookingCareInstruction : BaseEntity<long>
{
public long BookingId { get; set; }
public Booking Booking { get; set; }
/// <summary>Encrypted at rest.</summary>
public string CurrentConditions { get; set; }
/// <summary>Encrypted at rest.</summary>
public string Medications { get; set; }
/// <summary>Encrypted at rest.</summary>
public string Allergies { get; set; }
/// <summary>Encrypted at rest.</summary>
public string SpecialInstructions { get; set; }
/// <summary>Encrypted at rest.</summary>
public string EmergencyContactName { get; set; }
/// <summary>Encrypted at rest.</summary>
public string EmergencyContactPhone { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,55 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// One <b>visit</b> within a booking — always ≥ 1 per booking, even for a single-visit engagement, so the
/// EVV/payout path is uniform. Each session is independently scheduled, EVV-verified, and payout-accrued.
/// <para>
/// The sum of every session's <see cref="VisitPayoutAmount"/> exactly equals the booking's
/// <c>nurse_payout_amount</c> (integer split, remainder on the last session — no Rial created or lost).
/// <see cref="PayoutEligibleAt"/> is set on completion and is the per-session payout gate consumed by b13.
/// </para>
/// </summary>
public class BookingSession : BaseEntity<long>
{
public long BookingId { get; set; }
public Booking Booking { get; set; } = null!;
/// <summary>1-based ordinal within the booking.</summary>
public int SessionIndex { get; set; }
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTimeStart { get; set; }
public TimeOnly ScheduledTimeEnd { get; set; }
/// <summary>This session's portion of the booking's <c>nurse_payout_amount</c> (IRR).</summary>
public long VisitPayoutAmount { get; set; }
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/>.</summary>
public string Status { get; private set; } = BookingSessionStatus.Scheduled;
/// <summary>Per-session dispute-window close, set on completion. The per-session payout gate (b13).</summary>
public DateTime? PayoutEligibleAt { get; private set; }
/// <summary>Set when this session is cancelled — references the cancellation snapshot. The typed
/// cancellation-event/refund record arrives in b11; nullable and unset here.</summary>
public long? CancellationEventId { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public VisitVerification? Verification { get; set; }
public bool CanTransitionTo(string target) => BookingSessionTransitions.CanTransition(Status, target);
public void TransitionTo(string target)
{
if (!BookingSessionTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal booking-session transition {Status} → {target}.");
Status = target;
}
public void SetPayoutEligible(DateTime at) => PayoutEligibleAt = at;
}
@@ -0,0 +1,23 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed status vocabulary of a <see cref="BookingSession"/> — one visit within a booking. Persisted
/// as stable snake_case codes. Edges live in <see cref="BookingSessionTransitions"/>.
/// </summary>
public static class BookingSessionStatus
{
/// <summary>Planned, no EVV check-in yet. The only state a cancellation refunds (un-started).</summary>
public const string Scheduled = "scheduled";
/// <summary>The nurse checked in (EVV open).</summary>
public const string InProgress = "in_progress";
/// <summary>The nurse checked out; the visit happened. Sets the session's payout-eligibility window.</summary>
public const string Completed = "completed";
/// <summary>No check-in by the no-show threshold (set by the no-show sweep). Terminal.</summary>
public const string Missed = "missed";
/// <summary>The session was cancelled before it started. Terminal.</summary>
public const string Cancelled = "cancelled";
}
@@ -0,0 +1,31 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The allowed status machine for <see cref="BookingSession"/>. A scheduled visit can start (check-in),
/// be cancelled, or be marked missed (no-show sweep); an in-progress visit can complete (check-out) or be
/// cancelled. Completed/missed/cancelled are terminal.
/// </summary>
public static class BookingSessionTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[BookingSessionStatus.Scheduled] =
[
BookingSessionStatus.InProgress,
BookingSessionStatus.Cancelled,
BookingSessionStatus.Missed
],
[BookingSessionStatus.InProgress] =
[
BookingSessionStatus.Completed,
BookingSessionStatus.Cancelled
],
[BookingSessionStatus.Completed] = [],
[BookingSessionStatus.Missed] = [],
[BookingSessionStatus.Cancelled] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -0,0 +1,31 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed status vocabulary of a <see cref="Booking"/> — the <b>post-payment</b> half of the
/// engagement lifecycle. Persisted as these stable snake_case codes (never a C# enum member name). The
/// allowed edges live in <see cref="BookingTransitions"/> and may never contradict EVV/session state.
/// </summary>
public static class BookingStatus
{
/// <summary>Transient — a booking is created here and immediately confirmed on captured payment.</summary>
public const string PendingPayment = "pending_payment";
/// <summary>Payment captured, sessions generated, care instructions may now be added. Bookable engagement.</summary>
public const string Confirmed = "confirmed";
/// <summary>At least one session has an open EVV check-in.</summary>
public const string InProgress = "in_progress";
/// <summary>Every session is completed/cancelled/missed; sets the dispute window.</summary>
public const string Completed = "completed";
/// <summary>A dispute was opened inside the dispute window (admin flow).</summary>
public const string Disputed = "disputed";
/// <summary>The dispute window closed / dispute resolved. Terminal — payout eligibility is derived
/// from <c>dispute_window_ends_at</c> + the ledger (b13), never from this status.</summary>
public const string Closed = "closed";
/// <summary>The engagement was cancelled by customer/nurse/admin. Terminal.</summary>
public const string Cancelled = "cancelled";
}
@@ -0,0 +1,27 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The allowed status machine for <see cref="Booking"/>. Every transition is validated here so an illegal
/// edge is a clean conflict (never a silent overwrite) and terminal states have no outgoing edge. A CHECK
/// constraint backs the terminal states in the DB; this table is the authoritative guard consulted by
/// <c>TransitionBookingStatusCommand</c> and the internal capture/check-in/check-out flows. No transition
/// may contradict EVV — that extra invariant is enforced by the handlers, not encodable here.
/// </summary>
public static class BookingTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[BookingStatus.PendingPayment] = [BookingStatus.Confirmed, BookingStatus.Cancelled],
[BookingStatus.Confirmed] = [BookingStatus.InProgress, BookingStatus.Cancelled],
[BookingStatus.InProgress] = [BookingStatus.Completed, BookingStatus.Cancelled],
[BookingStatus.Completed] = [BookingStatus.Disputed, BookingStatus.Closed],
[BookingStatus.Disputed] = [BookingStatus.Closed],
// Terminal states — no outgoing edges.
[BookingStatus.Closed] = [],
[BookingStatus.Cancelled] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -0,0 +1,15 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed code set for who initiated a cancellation — both the <c>cancellation_policies.applies_to</c>
/// dimension and the value frozen into <c>bookings.cancelled_by</c> at cancel time.
/// </summary>
public static class CancellationActor
{
public const string Customer = "customer";
public const string Nurse = "nurse";
public const string Admin = "admin";
public static bool IsValid(string value)
=> value is Customer or Nurse or Admin;
}
@@ -0,0 +1,65 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// A config-driven, snapshot-able cancellation/refund tier keyed by initiating actor + lead-time bucket.
/// The applicable row is resolved at cancel time by <c>(applies_to, hours-before-start)</c> and its
/// <see cref="Code"/> + <see cref="RefundPercentage"/> are <b>frozen onto the booking</b> — a later edit to
/// the policy row must never change a past cancellation.
/// <para>
/// The penalty/fee is modelled as a flat IRR amount (<see cref="FeeAmountIrr"/>) <b>plus</b> an optional
/// fraction (<see cref="FeeRate"/>): a tier uses whichever is non-zero/non-null. At MVP the customer tiers
/// carry neither; the nurse-no-show penalty is modelled but its posting is deferred to payouts (b13).
/// </para>
/// </summary>
public class CancellationPolicy : BaseEntity<long>
{
/// <summary>Stable unique code, e.g. <c>standard_24h</c>, <c>nurse_no_show</c>.</summary>
public string Code { get; set; } = null!;
/// <summary>Who the tier applies to — a <see cref="CancellationActor"/> code.</summary>
public string AppliesTo { get; set; } = null!;
/// <summary>Lower bound (inclusive) of the lead-time bucket in hours; null = open lower bound.</summary>
public int? HoursBeforeStartMin { get; set; }
/// <summary>Upper bound (exclusive) of the lead-time bucket in hours; null = open upper bound.</summary>
public int? HoursBeforeStartMax { get; set; }
/// <summary>Refund fraction of the un-started portion, 0100.</summary>
public decimal RefundPercentage { get; set; }
/// <summary>Flat cancellation fee / nurse penalty (IRR). 0 = none.</summary>
public long FeeAmountIrr { get; set; }
/// <summary>Optional cancellation fee / nurse penalty as a fraction. Null = none.</summary>
public decimal? FeeRate { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>True when a lead time of <paramref name="hoursBeforeStart"/> falls in this tier's half-open
/// bucket <c>[min, max)</c> (null bound = open).</summary>
public bool Covers(double hoursBeforeStart)
=> (HoursBeforeStartMin is null || hoursBeforeStart >= HoursBeforeStartMin)
&& (HoursBeforeStartMax is null || hoursBeforeStart < HoursBeforeStartMax);
}
/// <summary>Stable seed codes for the baseline cancellation tiers.</summary>
public static class CancellationPolicyCode
{
/// <summary>Customer, ≥ 24h before start → full refund.</summary>
public const string Standard24h = "standard_24h";
/// <summary>Customer, &lt; 24h before start → 50% refund.</summary>
public const string StandardInside24h = "standard_inside_24h";
/// <summary>Nurse no-show / nurse-initiated → full refund (+ nurse penalty, posted in b13).</summary>
public const string NurseNoShow = "nurse_no_show";
/// <summary>Admin-initiated → full refund, no penalty.</summary>
public const string AdminCancellation = "admin_cancellation";
}
@@ -0,0 +1,41 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The Electronic Visit Verification (EVV) record — GPS + timestamped check-in/out proving a visit happened;
/// <b>required for payout</b>. The FK is on the <see cref="BookingSessionId"/> (not the booking) so each
/// visit in a multi-session engagement is verified independently. Raw GPS detail is sensitive and gated to
/// the owning nurse + admin only. <see cref="CheckInAddressMatch"/> is <b>advisory</b>: a mismatch flags an
/// admin review alert but never blocks the visit or withholds payout on its own.
/// </summary>
public class VisitVerification : BaseEntity<long>
{
public long BookingSessionId { get; set; }
public BookingSession Session { get; set; } = null!;
public DateTime? CheckInAt { get; set; }
public decimal? CheckInLat { get; set; }
public decimal? CheckInLng { get; set; }
public DateTime? CheckOutAt { get; set; }
public decimal? CheckOutLat { get; set; }
public decimal? CheckOutLng { get; set; }
/// <summary>Advisory: did the check-in fall within <c>evv_location_tolerance_meters</c> of the booking
/// address? Null when GPS was unavailable at check-in (still allowed, flagged).</summary>
public bool? CheckInAddressMatch { get; set; }
/// <summary>The computed check-in distance to the booking address, for the admin review screen.</summary>
public decimal? CheckInDistanceMeters { get; set; }
/// <summary>Guarded — mutated only through the transition helpers.</summary>
public string Status { get; private set; } = VisitVerificationStatus.Pending;
public DateTimeOffset? DeletedAt { get; set; }
public void MarkCheckedIn() => Status = VisitVerificationStatus.CheckedIn;
public void MarkCompleted() => Status = VisitVerificationStatus.Completed;
}
@@ -0,0 +1,18 @@
namespace Baya.Domain.Entities.Booking;
/// <summary>
/// The closed status vocabulary of a <see cref="VisitVerification"/> (EVV). It stays consistent with the
/// parent booking/session state via the documented mapping: <c>checked_in</c> ↔ session <c>in_progress</c>
/// ↔ booking <c>in_progress</c>; <c>completed</c> ↔ session <c>completed</c>.
/// </summary>
public static class VisitVerificationStatus
{
/// <summary>No check-in recorded yet.</summary>
public const string Pending = "pending";
/// <summary>An open check-in (GPS + timestamp captured), awaiting check-out.</summary>
public const string CheckedIn = "checked_in";
/// <summary>Both check-in and check-out recorded — the proof the visit happened.</summary>
public const string Completed = "completed";
}