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