backend phase 13 & frontend phase 6

This commit is contained in:
hamid
2026-07-09 04:09:35 +03:30
parent dc64472631
commit de53f9d8a6
97 changed files with 11969 additions and 77 deletions
@@ -0,0 +1,49 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A deterministic, network-free mock <see cref="IBankTransferProvider" /> for the PAYA/SATNA payout rail. It
/// moves <b>no money</b>: every instruction gets a deterministic <c>transfer_reference</c> and settles
/// <see cref="BankTransferStatus.Paid" /> (the mock collapses the real <c>submitted → paid</c> reconciliation
/// into one step). It <b>honours</b> the <see cref="PayoutInstruction.Method" /> chosen by the handler (PAYA vs
/// SATNA by the config threshold) and echoes it back. A config switch forces a deterministic failure so the
/// <c>partially_failed</c>/retry paths are testable: <see cref="BankTransferOptions.ForceFailure" /> fails every
/// instruction (→ whole-batch failure), and <see cref="BankTransferOptions.FailIban" /> fails just that one
/// destination (→ partial failure). A real transferor (Jibit / Vandar / Sadad payout) replaces this registration
/// only — the source settlement account, per-nurse Sheba, and the reconciliation callback are its concern.
/// </summary>
public sealed class MockBankTransferProvider(IOptions<SeamOptions> options) : IBankTransferProvider
{
private readonly BankTransferOptions _options = options.Value.BankTransfer;
public ValueTask<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
long payoutBatchId,
IReadOnlyList<PayoutInstruction> instructions,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var results = new List<PayoutInstructionResult>(instructions.Count);
foreach (var instruction in instructions)
{
var fail = _options.ForceFailure
|| (!string.IsNullOrEmpty(_options.FailIban)
&& string.Equals(instruction.Iban, _options.FailIban, StringComparison.Ordinal));
results.Add(fail
? new PayoutInstructionResult(instruction.PayoutId, BankTransferStatus.Failed, null, instruction.Method, "provider_declined")
: new PayoutInstructionResult(
instruction.PayoutId, BankTransferStatus.Paid,
TransferReference: $"mock-payout-{payoutBatchId}-{instruction.PayoutId}-{idempotencyKey}",
instruction.Method, FailureReason: null));
}
return ValueTask.FromResult(new PayoutBatchSubmitResult(
ExternalBatchRef: $"mock-batch-{payoutBatchId}-{idempotencyKey}", results));
}
public ValueTask<BankTransferStatus> GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_options.ForceFailure ? BankTransferStatus.Failed : BankTransferStatus.Paid);
}
@@ -19,6 +19,24 @@ public sealed class SeamOptions
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
public CurrencyOptions Currency { get; set; } = new();
public BankTransferOptions BankTransfer { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IBankTransferProvider</c> (b13 PAYA/SATNA payouts). By default every instruction settles
/// paid with a deterministic transfer reference and no money moves. Set <see cref="ForceFailure"/> to fail the
/// whole batch (→ <c>failed</c>) or <see cref="FailIban"/> to fail just one destination (→ <c>partially_failed</c>,
/// so the retry path is testable). The real transferor ignores these — the source settlement account, per-nurse
/// Sheba, and the reconciliation callback come from provider config.
/// </summary>
public sealed class BankTransferOptions
{
/// <summary>When true, every payout instruction is rejected so the whole-batch-failure path is testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>A designated IBAN that is rejected while others succeed — exercises the <c>partially_failed</c>
/// batch outcome and the single-payout retry.</summary>
public string FailIban { get; set; } = string.Empty;
}
/// <summary>
@@ -73,6 +73,13 @@ public static class ServiceCollectionExtension
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
// Payout bank rail (backend-phase-13). The deterministic MockBankTransferProvider settles every PAYA/SATNA
// instruction paid with no money movement; a config switch forces whole-batch/single-row failures so the
// partially_failed + retry paths are testable. A real transferor (Jibit/Vandar/Sadad payout) with a
// registered source settlement account + reconciliation callback swaps in by a registration change only —
// the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop.
services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>();
return services;
}
}
@@ -165,5 +165,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{
builder.Property(g => g.ConfigJson).HasConversion(encrypted);
});
// b13 payout snapshot: the nurse's IBAN is frozen onto each payout at build time and encrypted at rest
// through the same seam. Reads mask it to the last 4 digits — the plaintext IBAN is never serialized.
modelBuilder.Entity<Baya.Domain.Entities.Payouts.NursePayout>(builder =>
{
builder.Property(p => p.IbanSnapshot).HasConversion(encrypted);
});
}
}
@@ -49,6 +49,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
(22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."),
(23, "require_bnpl_settlement_for_payout", "false", ConfigDataType.Bool, "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard)."),
];
return rows
@@ -0,0 +1,32 @@
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PayoutsConfig;
/// <summary>
/// <c>nurse_payout_batches</c> — the weekly aggregation, in the dedicated <c>payouts</c> schema. The
/// <c>total_amount = Σ payouts</c> / <c>payout_count = COUNT(payouts)</c> invariants are enforced by the handler
/// when the rows are materialized (a cross-row aggregate can't be a single-row DB CHECK); the periods are
/// holiday-shifted before insert. 1:N → <c>nurse_payouts</c>.
/// </summary>
internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration<NursePayoutBatch>
{
public void Configure(EntityTypeBuilder<NursePayoutBatch> builder)
{
builder.ToTable("NursePayoutBatches", "payouts");
builder.Property(b => b.Status).HasMaxLength(30).IsRequired();
builder.Property(b => b.FailureNotes).HasMaxLength(1000);
builder.HasIndex(b => b.Status);
builder.HasIndex(b => b.ProcessingDate);
builder.HasMany(b => b.Payouts).WithOne(p => p.Batch).HasForeignKey(p => p.BatchId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired();
builder.HasQueryFilter(b => b.DeletedAt == null);
}
}
@@ -0,0 +1,31 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payouts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PayoutsConfig;
/// <summary>
/// <c>nurse_payout_booking_links</c> — the structural anti-double-pay guard. <c>UNIQUE(booking_id)</c> is
/// <b>unconditional</b> (no soft-delete filter) so a booking can be paid in exactly one payout across all batches,
/// ever — a duplicate insert is the already-paid signal the build handler catches. N:1 → <c>nurse_payouts</c>;
/// 1:1 → <c>bookings</c> (and, for a future per-session model, <c>booking_sessions</c>).
/// </summary>
internal sealed class NursePayoutBookingLinkConfig : IEntityTypeConfiguration<NursePayoutBookingLink>
{
public void Configure(EntityTypeBuilder<NursePayoutBookingLink> builder)
{
builder.ToTable("NursePayoutBookingLinks", "payouts");
// The hard guard: one payout per booking, forever. Unconditional (not filtered on DeletedAt) so a removed
// link can never re-open a booking for a second, irreversible transfer.
builder.HasIndex(l => l.BookingId).IsUnique();
builder.HasIndex(l => l.PayoutId);
builder.HasOne<NursePayout>().WithMany(p => p.BookingLinks).HasForeignKey(l => l.PayoutId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(l => l.BookingId).IsRequired();
builder.HasOne<BookingSession>().WithMany().HasForeignKey(l => l.SessionId).IsRequired(false);
builder.HasQueryFilter(l => l.DeletedAt == null);
}
}
@@ -0,0 +1,40 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payouts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PayoutsConfig;
/// <summary>
/// <c>nurse_payouts</c> — one row per nurse per batch. The <c>net = gross clawback</c> decomposition + all
/// amounts non-negative + <c>net ≥ 0</c> (never a negative transfer) is a DB CHECK mirroring b9/b11.
/// <c>iban_snapshot</c> is encrypted at rest (converter wired in <c>ApplicationDbContext</c>) and frozen at build
/// time from the nurse's verified primary account. Paid-ness is derived from a link row + the ledger — there is
/// no boolean flag. N:1 → batch / nurse_profiles / nurse_bank_accounts; 1:N → links.
/// </summary>
internal sealed class NursePayoutConfig : IEntityTypeConfiguration<NursePayout>
{
public void Configure(EntityTypeBuilder<NursePayout> builder)
{
builder.ToTable("NursePayouts", "payouts", t => t.HasCheckConstraint(
"CK_NursePayouts_NetSplit",
"[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] " +
"AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0"));
builder.Property(p => p.IbanSnapshot).IsRequired();
builder.Property(p => p.Status).HasMaxLength(20).IsRequired();
builder.Property(p => p.TransferReference).HasMaxLength(200);
builder.Property(p => p.FailureReason).HasMaxLength(500);
builder.HasIndex(p => p.BatchId);
builder.HasIndex(p => p.NurseId);
builder.HasIndex(p => p.Status);
builder.HasMany(p => p.BookingLinks).WithOne().HasForeignKey(l => l.PayoutId).IsRequired();
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(p => p.NurseId).IsRequired();
builder.HasOne<NurseBankAccount>().WithMany().HasForeignKey(p => p.BankAccountId).IsRequired();
builder.HasQueryFilter(p => p.DeletedAt == null);
}
}
@@ -0,0 +1,248 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class NursePayoutEngine : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "payouts");
migrationBuilder.CreateTable(
name: "NursePayoutBatches",
schema: "payouts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PeriodStart = table.Column<DateOnly>(type: "date", nullable: false),
PeriodEnd = table.Column<DateOnly>(type: "date", nullable: false),
ProcessingDate = table.Column<DateOnly>(type: "date", nullable: false),
TotalAmount = table.Column<long>(type: "bigint", nullable: false),
PayoutCount = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
InitiatedByAdminId = table.Column<int>(type: "int", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
FailureNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NursePayoutBatches", x => x.Id);
table.ForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
column: x => x.InitiatedByAdminId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NursePayouts",
schema: "payouts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BatchId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BankAccountId = table.Column<long>(type: "bigint", nullable: false),
IbanSnapshot = table.Column<string>(type: "nvarchar(max)", nullable: false),
GrossEarningsIrr = table.Column<long>(type: "bigint", nullable: false),
ClawbackAppliedIrr = table.Column<long>(type: "bigint", nullable: false),
NetAmountIrr = table.Column<long>(type: "bigint", nullable: false),
Amount = table.Column<long>(type: "bigint", nullable: false),
BookingCount = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
TransferReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
PaidAt = table.Column<DateTime>(type: "datetime2", nullable: true),
FailureReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NursePayouts", x => x.Id);
table.CheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0");
table.ForeignKey(
name: "FK_NursePayouts_NurseBankAccounts_BankAccountId",
column: x => x.BankAccountId,
principalSchema: "usr",
principalTable: "NurseBankAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NursePayouts_NursePayoutBatches_BatchId",
column: x => x.BatchId,
principalSchema: "payouts",
principalTable: "NursePayoutBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NursePayouts_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NursePayoutBookingLinks",
schema: "payouts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PayoutId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
SessionId = table.Column<long>(type: "bigint", nullable: true),
PayoutAmountIrr = table.Column<long>(type: "bigint", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NursePayoutBookingLinks", x => x.Id);
table.ForeignKey(
name: "FK_NursePayoutBookingLinks_BookingSessions_SessionId",
column: x => x.SessionId,
principalSchema: "booking",
principalTable: "BookingSessions",
principalColumn: "Id");
table.ForeignKey(
name: "FK_NursePayoutBookingLinks_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NursePayoutBookingLinks_NursePayouts_PayoutId",
column: x => x.PayoutId,
principalSchema: "payouts",
principalTable: "NursePayouts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 22L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", "payout_satna_threshold_irr", null, null, "1000000000" },
{ 23L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", "require_bnpl_settlement_for_payout", null, null, "false" }
});
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBatches_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
column: "InitiatedByAdminId");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBatches_ProcessingDate",
schema: "payouts",
table: "NursePayoutBatches",
column: "ProcessingDate");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBatches_Status",
schema: "payouts",
table: "NursePayoutBatches",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBookingLinks_BookingId",
schema: "payouts",
table: "NursePayoutBookingLinks",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBookingLinks_PayoutId",
schema: "payouts",
table: "NursePayoutBookingLinks",
column: "PayoutId");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBookingLinks_SessionId",
schema: "payouts",
table: "NursePayoutBookingLinks",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_BankAccountId",
schema: "payouts",
table: "NursePayouts",
column: "BankAccountId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_BatchId",
schema: "payouts",
table: "NursePayouts",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_NurseId",
schema: "payouts",
table: "NursePayouts",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_Status",
schema: "payouts",
table: "NursePayouts",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NursePayoutBookingLinks",
schema: "payouts");
migrationBuilder.DropTable(
name: "NursePayouts",
schema: "payouts");
migrationBuilder.DropTable(
name: "NursePayoutBatches",
schema: "payouts");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 22L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 23L);
}
}
}
@@ -1291,6 +1291,24 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.",
Key = "refund_assume_nurse_paid",
Value = "false"
},
new
{
Id = 22L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).",
Key = "payout_satna_threshold_irr",
Value = "1000000000"
},
new
{
Id = 23L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).",
Key = "require_bnpl_settlement_for_payout",
Value = "false"
});
});
@@ -3186,6 +3204,200 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("PaymentWebhookEvents", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<long>("BankAccountId")
.HasColumnType("bigint");
b.Property<long>("BatchId")
.HasColumnType("bigint");
b.Property<int>("BookingCount")
.HasColumnType("int");
b.Property<long>("ClawbackAppliedIrr")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<long>("GrossEarningsIrr")
.HasColumnType("bigint");
b.Property<string>("IbanSnapshot")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NetAmountIrr")
.HasColumnType("bigint");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<DateTime?>("PaidAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("TransferReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("BankAccountId");
b.HasIndex("BatchId");
b.HasIndex("NurseId");
b.HasIndex("Status");
b.ToTable("NursePayouts", "payouts", t =>
{
t.HasCheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("FailureNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("InitiatedByAdminId")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("PayoutCount")
.HasColumnType("int");
b.Property<DateOnly>("PeriodEnd")
.HasColumnType("date");
b.Property<DateOnly>("PeriodStart")
.HasColumnType("date");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("ProcessingDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<long>("TotalAmount")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InitiatedByAdminId");
b.HasIndex("ProcessingDate");
b.HasIndex("Status");
b.ToTable("NursePayoutBatches", "payouts");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("PayoutAmountIrr")
.HasColumnType("bigint");
b.Property<long>("PayoutId")
.HasColumnType("bigint");
b.Property<long?>("SessionId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.HasIndex("PayoutId");
b.HasIndex("SessionId");
b.ToTable("NursePayoutBookingLinks", "payouts");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.Property<long>("Id")
@@ -4674,6 +4886,57 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseBankAccount", null)
.WithMany()
.HasForeignKey("BankAccountId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payouts.NursePayoutBatch", "Batch")
.WithMany("Payouts")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("InitiatedByAdminId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
.WithMany("BookingLinks")
.HasForeignKey("PayoutId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Booking.BookingSession", null)
.WithMany()
.HasForeignKey("SessionId");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
@@ -4942,6 +5205,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("BankAccounts");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.Navigation("BookingLinks");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b =>
{
b.Navigation("Payouts");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Navigation("Claims");
@@ -26,6 +26,7 @@ public class UnitOfWork : IUnitOfWork
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -50,6 +51,7 @@ public class UnitOfWork : IUnitOfWork
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db);
PayoutRepository = new PayoutRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,241 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PayoutRepository : BaseAsyncRepository<NursePayoutBatch>, IPayoutRepository
{
public PayoutRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
// Eligible = completed AND its dispute window closed by `now` (and by the period end, so period_end bounds the
// run) AND no active refund reversed its money AND not already paid in a link row. No lower period bound, so a
// booking that missed an earlier batch is still swept — it must eventually be paid. When
// requireBnplSettlement is set, a BNPL-paid booking is held until its provider settlement is received.
private IQueryable<EligibleBookingRow> EligibleBookingsQuery(DateOnly periodEnd, DateTime now, bool requireBnplSettlement)
{
var windowEnd = periodEnd.ToDateTime(TimeOnly.MaxValue);
var query = from b in DbContext.Set<Booking>().AsNoTracking()
where b.Status == BookingStatus.Completed
&& b.DisputeWindowEndsAt != null
&& b.DisputeWindowEndsAt < now
&& b.DisputeWindowEndsAt <= windowEnd
&& !DbContext.Set<Refund>()
.Any(r => r.BookingId == b.Id && r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected)
&& !DbContext.Set<NursePayoutBookingLink>().IgnoreQueryFilters()
.Any(l => l.BookingId == b.Id)
select b;
if (requireBnplSettlement)
// Hold a BNPL-paid booking until its 1:1 bnpl_transaction reports a settlement (settled_at set).
query = query.Where(b => !DbContext.Set<PaymentTransaction>()
.Any(t => t.BookingId == b.Id && t.Status == PaymentTransactionStatus.Succeeded
&& DbContext.Set<BnplTransaction>().Any(bt => bt.PaymentTransactionId == t.Id && bt.SettledAt == null)));
return query.Select(b => new EligibleBookingRow(b.NurseId, b.Id, b.NursePayoutAmount));
}
public async Task<IReadOnlyList<EligibleBookingRow>> GetEligibleBookingsAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken)
=> await EligibleBookingsQuery(periodEnd, now, requireBnplSettlement).ToListAsync(cancellationToken);
public async Task<PagedResult<EligibleNurseEarningsDto>> GetEligiblePreviewAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken)
{
var rows = await EligibleBookingsQuery(periodEnd, now, requireBnplSettlement).ToListAsync(cancellationToken);
var groups = rows
.GroupBy(r => r.NurseId)
.Select(g => new { NurseId = g.Key, Gross = g.Sum(x => x.PayoutAmountIrr), Count = g.Count() })
.OrderBy(x => x.NurseId)
.ToList();
var total = groups.Count;
var slice = groups.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var nurseIds = slice.Select(x => x.NurseId).ToList();
var names = await GetNurseNamesAsync(nurseIds, cancellationToken);
var clawbacks = await DbContext.Set<NurseClawback>().AsNoTracking()
.Where(c => nurseIds.Contains(c.NurseId) && c.Status == ClawbackStatus.Pending)
.GroupBy(c => c.NurseId)
.Select(g => new { NurseId = g.Key, Sum = g.Sum(x => x.AmountIrr) })
.ToDictionaryAsync(x => x.NurseId, x => x.Sum, cancellationToken);
var verified = (await DbContext.Set<NurseBankAccount>().AsNoTracking()
.Where(a => nurseIds.Contains(a.NurseId) && a.IsPrimary && a.IsVerified && a.MatchedNationalId == true)
.Select(a => a.NurseId)
.ToListAsync(cancellationToken))
.ToHashSet();
var items = slice.Select(x =>
{
var clawback = Math.Min(x.Gross, clawbacks.GetValueOrDefault(x.NurseId));
var net = x.Gross - clawback;
return new EligibleNurseEarningsDto(
x.NurseId, names.GetValueOrDefault(x.NurseId), x.Count,
x.Gross.ToString(), clawback.ToString(), net.ToString(), verified.Contains(x.NurseId));
}).ToList();
return new PagedResult<EligibleNurseEarningsDto>(items, total, page, pageSize);
}
public Task<VerifiedPayoutAccount?> GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken)
=> DbContext.Set<NurseBankAccount>().AsNoTracking()
.Where(a => a.NurseId == nurseId && a.IsPrimary && a.IsVerified && a.MatchedNationalId == true)
.Select(a => new VerifiedPayoutAccount(a.Id, a.Iban))
.FirstOrDefaultAsync(cancellationToken);
public async Task<IReadOnlyDictionary<long, string>> GetNurseNamesAsync(IReadOnlyList<long> nurseIds, CancellationToken cancellationToken)
{
if (nurseIds.Count == 0)
return new Dictionary<long, string>();
var rows = await (from n in DbContext.Set<NurseProfile>().AsNoTracking()
where nurseIds.Contains(n.Id)
join u in DbContext.Set<User>() on n.UserId equals u.Id
select new { n.Id, u.Name, u.FamilyName })
.ToListAsync(cancellationToken);
return rows.ToDictionary(x => x.Id, x => $"{x.Name} {x.FamilyName}".Trim());
}
public async Task<long> GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken)
=> await DbContext.Set<NurseClawback>().AsNoTracking()
.Where(c => c.NurseId == nurseId && c.Status == ClawbackStatus.Pending)
.SumAsync(c => (long?)c.AmountIrr, cancellationToken) ?? 0;
public async Task<IReadOnlyList<NurseClawback>> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken)
=> await DbContext.Set<NurseClawback>()
.Where(c => c.NurseId == nurseId && c.Status == ClawbackStatus.Pending)
.OrderBy(c => c.Id)
.ToListAsync(cancellationToken);
public Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken)
=> base.AddAsync(batch);
public Task<NursePayoutBatch?> GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken)
=> DbContext.Set<NursePayoutBatch>()
.Include(b => b.Payouts).ThenInclude(p => p.BookingLinks)
.FirstOrDefaultAsync(b => b.Id == batchId, cancellationToken);
public Task<NursePayout?> GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken)
=> DbContext.Set<NursePayout>()
.Include(p => p.Batch)
.FirstOrDefaultAsync(p => p.Id == payoutId, cancellationToken);
public Task<bool> LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AsNoTracking()
.AnyAsync(l => l.SourceRefType == LedgerSourceRefType.NursePayout && l.SourceRefId == payoutId, cancellationToken);
public async Task<PagedResult<PayoutBatchDto>> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = DbContext.Set<NursePayoutBatch>().AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(b => b.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(b => b.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(b => new
{
b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount, b.PayoutCount,
b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt
})
.ToListAsync(cancellationToken);
var items = rows.Select(b => new PayoutBatchDto(
b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount.ToString(), b.PayoutCount,
b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt))
.ToList();
return new PagedResult<PayoutBatchDto>(items, total, page, pageSize);
}
public async Task<PayoutBatchDetailDto?> GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken)
{
var header = await DbContext.Set<NursePayoutBatch>().AsNoTracking()
.Where(b => b.Id == batchId)
.Select(b => new PayoutBatchDto(
b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount.ToString(), b.PayoutCount,
b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
if (header is null)
return null;
var payoutsQuery = DbContext.Set<NursePayout>().AsNoTracking().Where(p => p.BatchId == batchId);
var total = await payoutsQuery.CountAsync(cancellationToken);
var rows = await payoutsQuery
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new
{
p.Id, p.NurseId, p.IbanSnapshot, p.GrossEarningsIrr, p.ClawbackAppliedIrr, p.NetAmountIrr,
p.Amount, p.BookingCount, p.Status, p.TransferReference, p.PaidAt, p.FailureReason,
Links = p.BookingLinks.Select(l => new { l.BookingId, l.SessionId, l.PayoutAmountIrr }).ToList()
})
.ToListAsync(cancellationToken);
var names = await GetNurseNamesAsync(rows.Select(r => r.NurseId).Distinct().ToList(), cancellationToken);
var payouts = rows.Select(p => new PayoutDto(
p.Id, p.NurseId, names.GetValueOrDefault(p.NurseId), MaskIban(p.IbanSnapshot),
p.GrossEarningsIrr.ToString(), p.ClawbackAppliedIrr.ToString(), p.NetAmountIrr.ToString(),
p.Amount.ToString(), p.BookingCount, p.Status, p.TransferReference, p.PaidAt, p.FailureReason,
p.Links.Select(l => new PayoutBookingLinkDto(l.BookingId, l.SessionId, l.PayoutAmountIrr.ToString())).ToList()))
.ToList();
return new PayoutBatchDetailDto(header, payouts, total, page, pageSize);
}
public async Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = from p in DbContext.Set<NursePayout>().AsNoTracking()
where p.NurseId == nurseId
join b in DbContext.Set<NursePayoutBatch>() on p.BatchId equals b.Id
orderby p.Id descending
select new
{
p.Id, p.BatchId, p.Status, p.GrossEarningsIrr, p.ClawbackAppliedIrr, p.NetAmountIrr,
p.IbanSnapshot, p.TransferReference, p.PaidAt, b.PeriodStart, b.PeriodEnd
};
var total = await query.CountAsync(cancellationToken);
var rows = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
var items = rows.Select(p => new NursePayoutHistoryDto(
p.Id, p.BatchId, p.Status, p.GrossEarningsIrr.ToString(), p.ClawbackAppliedIrr.ToString(),
p.NetAmountIrr.ToString(), MaskIban(p.IbanSnapshot), p.TransferReference, p.PaidAt,
p.PeriodStart, p.PeriodEnd))
.ToList();
return new PagedResult<NursePayoutHistoryDto>(items, total, page, pageSize);
}
// Show only the last 4 digits of the IBAN — the plaintext snapshot never leaves the server.
private static string MaskIban(string iban)
{
if (string.IsNullOrEmpty(iban))
return string.Empty;
return iban.Length <= 4
? new string('•', iban.Length)
: $"{new string('•', iban.Length - 4)}{iban[^4..]}";
}
}
@@ -54,10 +54,10 @@ public static class ServiceCollectionExtensions
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real
// impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps
// this registration for the authoritative payout-link lookup.
services.AddScoped<INursePayoutStatus, NursePayoutStatusService>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). b13 now owns the authoritative
// impl: a booking is paid iff a nurse_payout_booking_links row ties it to a `paid` nurse_payouts row. This
// supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Payouts;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The <b>authoritative</b> b13 implementation of <see cref="INursePayoutStatus"/> — it answers "was the nurse
/// already paid for this booking?" from the real payout ledger: a booking is paid iff a
/// <c>nurse_payout_booking_links</c> row ties it to a <c>nurse_payouts</c> row in status <c>paid</c> (a
/// confirmed, irreversible transfer). This supersedes the b11 interim <c>NursePayoutStatusService</c> that
/// derived it from the dispute-window close. The refund pre-payout/clawback fork is unchanged — it just now
/// forks on the true paid-state. The <c>refund_assume_nurse_paid</c> config switch still forces the paid answer
/// for ops/testing.
/// </summary>
internal sealed class NursePayoutLinkStatusService(
ApplicationDbContext dbContext,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
return await (from l in dbContext.Set<NursePayoutBookingLink>().AsNoTracking()
where l.BookingId == bookingId
join p in dbContext.Set<NursePayout>() on l.PayoutId equals p.Id
where p.Status == PayoutStatus.Paid
select l.Id)
.AnyAsync(cancellationToken);
}
}
@@ -1,35 +0,0 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The interim implementation of <see cref="INursePayoutStatus"/> until b13 ships <c>nurse_payouts</c> /
/// <c>nurse_payout_booking_links</c>. It derives "already paid?" from the booking's dispute-window close — the
/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path
/// is the fallback. A <c>refund_assume_nurse_paid</c> config switch forces the paid answer for ops/testing. b13
/// swaps this registration for the authoritative payout-link lookup.
/// </summary>
internal sealed class NursePayoutStatusService(
ApplicationDbContext dbContext,
IDateTimeProvider dateTimeProvider,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
var now = dateTimeProvider.UtcNow.UtcDateTime;
var windowEnd = await dbContext.Set<BookingEntity>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => b.DisputeWindowEndsAt)
.FirstOrDefaultAsync(cancellationToken);
return windowEnd is { } endsAt && endsAt <= now;
}
}