backend phase 13 & frontend phase 6
This commit is contained in:
+49
@@ -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>
|
||||
|
||||
+7
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+5260
File diff suppressed because it is too large
Load Diff
+248
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+273
@@ -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");
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
+241
@@ -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..]}";
|
||||
}
|
||||
}
|
||||
+4
-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>();
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user