backend phase 13 & frontend phase 6
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
|
||||
using Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
|
||||
using Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
|
||||
using Baya.Application.Features.Payouts.Queries.GetBatchDetail;
|
||||
using Baya.Application.Features.Payouts.Queries.ListPayoutBatches;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Baya.WebFramework.ServiceConfiguration;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Admin payout console: preview eligible earnings, open a draft batch, submit it to the (mocked) PAYA/SATNA
|
||||
/// rail, retry a failed payout, mark a reconciled bank rejection, and read batches. Generating and processing a
|
||||
/// batch move real (mocked) money and are rate-limited as money endpoints. One payout per booking is guaranteed by
|
||||
/// the <c>nurse_payout_booking_links.booking_id</c> UNIQUE; processing is the one irreversible step.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/admin_payouts")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||
[Display(Description = "Admin payout batches: eligible preview, generate, process, retry, mark-failed, read")]
|
||||
public sealed class AdminPayoutsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("eligible")]
|
||||
[ProducesOkApiResponseType<PagedResult<EligibleNurseEarningsDto>>]
|
||||
public async Task<IActionResult> Eligible([FromQuery] ComputeEligibleEarningsQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
[HttpPost("batches")]
|
||||
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
|
||||
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPost("batches/{id}/process")]
|
||||
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
|
||||
public async Task<IActionResult> Process(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ExecutePayoutBatchCommand(id), cancellationToken));
|
||||
|
||||
[HttpGet("batches/{id}")]
|
||||
[ProducesOkApiResponseType<PayoutBatchDetailDto>]
|
||||
public async Task<IActionResult> Get(long id, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default)
|
||||
=> OperationResult(await sender.Send(new GetBatchDetailQuery(id, page, pageSize), cancellationToken));
|
||||
|
||||
[HttpGet("batches")]
|
||||
[ProducesOkApiResponseType<PagedResult<PayoutBatchDto>>]
|
||||
public async Task<IActionResult> List([FromQuery] ListPayoutBatchesQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
[HttpPost("{payoutId}/retry")]
|
||||
[ProducesOkApiResponseType<bool>]
|
||||
public async Task<IActionResult> Retry(long payoutId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new RetryFailedPayoutCommand(payoutId), cancellationToken));
|
||||
|
||||
[HttpPost("{payoutId}/mark_failed")]
|
||||
[ProducesOkApiResponseType<bool>]
|
||||
public async Task<IActionResult> MarkFailed(long payoutId, MarkPayoutFailedBody body, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new MarkPayoutFailedCommand(payoutId, body.FailureReason), cancellationToken));
|
||||
|
||||
/// <summary>The mark-failed body (the payout id comes from the route).</summary>
|
||||
public record MarkPayoutFailedBody(string FailureReason);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>The signed-in nurse's own payout history — tenancy-scoped to <c>ICurrentUser</c> (a nurse can never
|
||||
/// read another nurse's payouts). Feeds f12's earnings screen: status, net, masked IBAN + transfer reference, and
|
||||
/// any clawback applied.</summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/nurse_payouts")]
|
||||
[Authorize]
|
||||
[Display(Description = "The signed-in nurse's payout history")]
|
||||
public sealed class NursePayoutsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("history")]
|
||||
[ProducesOkApiResponseType<PagedResult<NursePayoutHistoryDto>>]
|
||||
public async Task<IActionResult> History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The swappable PAYA/SATNA <b>bank payout rail</b> — the mocked stand-in for a real transferor (Jibit / Vandar /
|
||||
/// Sadad payout API) that moves money out of the platform's registered source settlement account to each nurse's
|
||||
/// verified Sheba. This is the one irreversible money-out step, so the seam carries an
|
||||
/// <paramref name="idempotencyKey" /> and the whole submit is idempotent: a retried
|
||||
/// <see cref="SubmitPayoutBatchAsync" /> for the same key never re-sends an already-<c>paid</c> instruction.
|
||||
/// Handlers depend only on this contract; the concrete provider is a config-selected registration change, never
|
||||
/// an <c>if (mock)</c> branch. <b>Every amount crossing this seam is IRR <c>long</c>.</b>
|
||||
/// </summary>
|
||||
public interface IBankTransferProvider
|
||||
{
|
||||
/// <summary>Submits one <see cref="PayoutInstruction" /> per payout to the rail and returns a deterministic
|
||||
/// <c>externalBatchRef</c> plus a per-instruction result carrying the bank track id
|
||||
/// (<c>transfer_reference</c>) and status. The mock moves no money; a real transferor registers the batch
|
||||
/// against the source account and routes each PAYA/SATNA transfer.</summary>
|
||||
ValueTask<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
|
||||
long payoutBatchId,
|
||||
IReadOnlyList<PayoutInstruction> instructions,
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The reconciliation read — echoes the batch's settled status (the real callback flips
|
||||
/// <c>submitted → paid/failed</c>).</summary>
|
||||
ValueTask<BankTransferStatus> GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The settled state of a single transfer (or the batch echo). Forward-only on the payout row.</summary>
|
||||
public enum BankTransferStatus
|
||||
{
|
||||
/// <summary>The rail accepted the instruction (a track id was issued) but the transfer is not yet confirmed.</summary>
|
||||
Submitted,
|
||||
|
||||
/// <summary>The transfer is confirmed — an irreversible IBAN movement.</summary>
|
||||
Paid,
|
||||
|
||||
/// <summary>The rail rejected the transfer (closed day / insufficient provider balance / bad Sheba).</summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>The two Iranian interbank rails. Persisted/echoed as these stable codes; selection is by value
|
||||
/// (SATNA for high-value rows above a config threshold, else PAYA).</summary>
|
||||
public static class BankTransferMethod
|
||||
{
|
||||
/// <summary>Batch ACH-style clearing — the default for ordinary-value payouts.</summary>
|
||||
public const string Paya = "paya";
|
||||
|
||||
/// <summary>Real-time gross settlement — chosen for high-value rows above the SATNA threshold.</summary>
|
||||
public const string Satna = "satna";
|
||||
}
|
||||
|
||||
/// <param name="PayoutId">The <c>nurse_payouts</c> row this instruction settles.</param>
|
||||
/// <param name="Iban">The nurse's verified primary Sheba (the payout destination).</param>
|
||||
/// <param name="AmountIrr">The net amount to transfer (IRR).</param>
|
||||
/// <param name="Method">A <see cref="BankTransferMethod" /> code — PAYA or SATNA.</param>
|
||||
public sealed record PayoutInstruction(long PayoutId, string Iban, long AmountIrr, string Method);
|
||||
|
||||
/// <param name="ExternalBatchRef">The rail's own batch reference, for reconciliation.</param>
|
||||
/// <param name="Results">One result per submitted instruction.</param>
|
||||
public sealed record PayoutBatchSubmitResult(string ExternalBatchRef, IReadOnlyList<PayoutInstructionResult> Results);
|
||||
|
||||
/// <param name="PayoutId">The payout this result belongs to.</param>
|
||||
/// <param name="Status">The transfer outcome — drives the payout status machine.</param>
|
||||
/// <param name="TransferReference">The bank track id, when the rail accepted it; null on failure.</param>
|
||||
/// <param name="Method">The rail the transfer took (the honoured <see cref="PayoutInstruction.Method" />).</param>
|
||||
/// <param name="FailureReason">Why the rail rejected it, when <see cref="Status" /> is
|
||||
/// <see cref="BankTransferStatus.Failed" />.</param>
|
||||
public sealed record PayoutInstructionResult(
|
||||
long PayoutId, BankTransferStatus Status, string? TransferReference, string Method, string? FailureReason);
|
||||
@@ -0,0 +1,77 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The payouts aggregate — the weekly batch, its per-nurse payouts, and the anti-double-pay booking links. Reads
|
||||
/// project to DTOs (<c>AsNoTracking</c> + <c>.Select</c>); writes load tracked rows. The payout <b>ledger legs</b>
|
||||
/// are appended through <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper) — this repo owns the
|
||||
/// payout rows and the money facts a batch is built from. Money is IRR <c>long</c>. The
|
||||
/// <c>nurse_payout_booking_links.booking_id</c> UNIQUE is the authoritative one-payout-per-booking backstop; the
|
||||
/// eligibility predicate's "not already linked" filter is the fast first line.
|
||||
/// </summary>
|
||||
public interface IPayoutRepository
|
||||
{
|
||||
// ---- eligibility (preview + build) ----
|
||||
|
||||
/// <summary>The payout-eligible, unpaid bookings for the window: <c>status='completed'</c> AND
|
||||
/// <c>dispute_window_ends_at < now</c> AND no active refund AND not already in a link row (and, when
|
||||
/// <paramref name="requireBnplSettlement"/> is set, its BNPL provider settlement is received). One row per
|
||||
/// booking (nurse + this booking's payout portion) — grouped by nurse in the build handler.</summary>
|
||||
Task<IReadOnlyList<EligibleBookingRow>> GetEligibleBookingsAsync(
|
||||
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The same eligibility set as a per-nurse preview (paginated), netting pending clawbacks and
|
||||
/// flagging any nurse without a verified primary IBAN.</summary>
|
||||
Task<PagedResult<EligibleNurseEarningsDto>> GetEligiblePreviewAsync(
|
||||
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's verified primary payout account (<c>is_primary=1 AND is_verified=1 AND
|
||||
/// matched_national_id=1</c>) — the first-payout gate. Null when the nurse has none (the payout is skipped
|
||||
/// with a recorded reason). The IBAN is decrypted by the EF converter on projection.</summary>
|
||||
Task<VerifiedPayoutAccount?> GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's display name (for the batch preview/detail), or null.</summary>
|
||||
Task<IReadOnlyDictionary<long, string>> GetNurseNamesAsync(IReadOnlyList<long> nurseIds, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The sum of the nurse's <c>pending</c> clawbacks (IRR) — netted (capped at earnings) into a payout
|
||||
/// at build time.</summary>
|
||||
Task<long> GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's <c>pending</c> clawbacks, oldest first, tracked — the execute step marks them
|
||||
/// <c>recovered</c> (with <c>recovered_in_payout_id</c> + <c>resolved_at</c>) up to the payout's frozen
|
||||
/// <c>clawback_applied_irr</c>.</summary>
|
||||
Task<IReadOnlyList<NurseClawback>> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
// ---- writes ----
|
||||
|
||||
Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked batch with its payouts + links — loaded by execute/retry to drive the transfers and
|
||||
/// post the ledger. Null when absent.</summary>
|
||||
Task<NursePayoutBatch?> GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>A single tracked payout (with its batch) — for retry/mark-failed. Null when absent.</summary>
|
||||
Task<NursePayout?> GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether a payout already has a posted ledger group — makes the execute ledger post idempotent so
|
||||
/// a retried execute never double-posts.</summary>
|
||||
Task<bool> LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken);
|
||||
|
||||
// ---- reads (admin + nurse) ----
|
||||
|
||||
Task<PagedResult<PayoutBatchDto>> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
Task<PayoutBatchDetailDto?> GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN.</summary>
|
||||
Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into
|
||||
/// the encrypted <c>iban_snapshot</c> at build time).</summary>
|
||||
public record VerifiedPayoutAccount(long BankAccountId, string Iban);
|
||||
@@ -22,6 +22,7 @@ public interface IUnitOfWork
|
||||
public IRefundRepository RefundRepository { get; }
|
||||
public IInvoiceRepository InvoiceRepository { get; }
|
||||
public IBnplRepository BnplRepository { get; }
|
||||
public IPayoutRepository PayoutRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
|
||||
|
||||
/// <summary>
|
||||
/// The one irreversible money-out step. Under <c>lock(payout:batch)</c> it submits the batch's unpaid payouts to
|
||||
/// the <see cref="IBankTransferProvider"/> (PAYA/SATNA by the config threshold), then for each accepted transfer
|
||||
/// posts the balanced payout ledger group (<c>DEBIT nurse_payable / CREDIT escrow_held</c>) via b10's helper and
|
||||
/// nets recovered clawbacks (<c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> + marks the row
|
||||
/// <c>recovered</c>). The forward-only payout status machine + the ledger-exists guard + the batch idempotency key
|
||||
/// make a retried execute never double-send a transfer or double-post the ledger.
|
||||
/// </summary>
|
||||
internal sealed class ExecutePayoutBatchCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IBankTransferProvider bankTransfer,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<ExecutePayoutBatchCommand, OperationResult<ExecutePayoutBatchResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ExecutePayoutBatchResult>> Handle(
|
||||
ExecutePayoutBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
|
||||
|
||||
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(request.BatchId, cancellationToken);
|
||||
if (batch is null)
|
||||
return OperationResult<ExecutePayoutBatchResult>.NotFoundResult("Payout batch not found.");
|
||||
|
||||
// Idempotent: a fully-settled batch has nothing left to submit.
|
||||
if (batch.Status == PayoutBatchStatus.Completed)
|
||||
return OperationResult<ExecutePayoutBatchResult>.SuccessResult(Summarize(batch));
|
||||
|
||||
if (batch.Status == PayoutBatchStatus.Failed)
|
||||
return OperationResult<ExecutePayoutBatchResult>.ConflictResult("This batch has already failed; open a new batch.");
|
||||
|
||||
var satnaThreshold = (long)await platformConfig.GetConfig<decimal>("payout_satna_threshold_irr", cancellationToken);
|
||||
|
||||
if (batch.Status == PayoutBatchStatus.Draft)
|
||||
batch.TransitionTo(PayoutBatchStatus.Processing, now);
|
||||
|
||||
// Only unpaid payouts are (re)submitted — an already-paid row is skipped by the status machine, never
|
||||
// re-sent. A zero-net payout (fully netted against clawback) has no transfer but still realizes recovery.
|
||||
var unpaid = batch.Payouts.Where(p => p.Status != PayoutStatus.Paid).ToList();
|
||||
var transferable = unpaid.Where(p => p.Amount > 0).ToList();
|
||||
|
||||
var resultsByPayout = new Dictionary<long, PayoutInstructionResult>();
|
||||
if (transferable.Count > 0)
|
||||
{
|
||||
var instructions = transferable
|
||||
.Select(p => PayoutSettlement.ToInstruction(p, satnaThreshold))
|
||||
.ToList();
|
||||
|
||||
var submit = await bankTransfer.SubmitPayoutBatchAsync(
|
||||
batch.Id, instructions, idempotencyKey: $"payout-batch:{batch.Id}", cancellationToken);
|
||||
resultsByPayout = submit.Results.ToDictionary(r => r.PayoutId);
|
||||
}
|
||||
|
||||
foreach (var payout in unpaid)
|
||||
{
|
||||
if (payout.Amount > 0)
|
||||
{
|
||||
var result = resultsByPayout.GetValueOrDefault(payout.Id);
|
||||
if (result is null || result.Status == BankTransferStatus.Failed)
|
||||
{
|
||||
payout.MarkFailed(result?.FailureReason ?? "provider_declined");
|
||||
continue;
|
||||
}
|
||||
|
||||
payout.MarkSubmitted(result.TransferReference ?? $"payout:{batch.Id}:{payout.Id}");
|
||||
if (result.Status == BankTransferStatus.Paid)
|
||||
payout.MarkPaid(now);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fully netted — no cash leaves, but the withheld earnings realize the clawback recovery now.
|
||||
payout.MarkSubmitted($"netted:{batch.Id}:{payout.Id}");
|
||||
payout.MarkPaid(now);
|
||||
}
|
||||
|
||||
// Only a settled (paid) payout posts the ledger + nets clawbacks — a still-submitted row waits for the
|
||||
// reconciliation callback (the real rail), a failed one does neither.
|
||||
if (payout.Status == PayoutStatus.Paid)
|
||||
{
|
||||
await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
|
||||
await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
batch.RecomputeSettlement(now);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<ExecutePayoutBatchResult>.SuccessResult(Summarize(batch));
|
||||
}
|
||||
|
||||
private static ExecutePayoutBatchResult Summarize(NursePayoutBatch batch)
|
||||
{
|
||||
var paid = batch.Payouts.Where(p => p.Status == PayoutStatus.Paid).ToList();
|
||||
var failed = batch.Payouts.Count(p => p.Status == PayoutStatus.Failed);
|
||||
var totalPaid = paid.Sum(p => p.Amount);
|
||||
return new ExecutePayoutBatchResult(batch.Id, batch.Status, paid.Count, failed, totalPaid.ToString());
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
|
||||
|
||||
public sealed class ExecutePayoutBatchCommandValidator : AbstractValidator<ExecutePayoutBatchCommand>
|
||||
{
|
||||
public ExecutePayoutBatchCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BatchId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
|
||||
|
||||
/// <summary>Submits a draft (or partially-failed) batch to the bank rail: transitions it to <c>processing</c>,
|
||||
/// sends one instruction per unpaid payout (PAYA/SATNA by the config threshold), posts the balanced payout ledger
|
||||
/// group out of <c>nurse_payable</c>, nets recovered clawbacks, and settles the batch <c>completed</c> or
|
||||
/// <c>partially_failed</c>. Idempotent — a retried call never re-sends an already-paid transfer or re-posts the
|
||||
/// ledger.</summary>
|
||||
public record ExecutePayoutBatchCommand(long BatchId) : IRequest<OperationResult<ExecutePayoutBatchResult>>;
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Holidays;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a weekly payout batch. Under <c>lock(payout:batch)</c> (so two runs can't grab the same bookings), it
|
||||
/// holiday-shifts the period end + processing date, selects the eligible unpaid bookings, groups them per nurse,
|
||||
/// and materializes payouts + booking links in one unit of work. The <b>BuildNursePayouts</b> and
|
||||
/// <b>LinkPayoutBookings</b> steps from the phase are cohesive private steps here (mirroring b11's
|
||||
/// <c>CreateRefund</c>). Netting caps clawbacks at whole recoverable amounts; a nurse without a verified primary
|
||||
/// IBAN is skipped with a recorded reason, never silently dropped. The <c>booking_id</c> UNIQUE link is the
|
||||
/// backstop that makes a re-run over an overlapping window unable to re-select an already-paid booking.
|
||||
/// </summary>
|
||||
internal sealed class GeneratePayoutBatchCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IHolidayCalendar holidays,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<GeneratePayoutBatchCommand, OperationResult<GeneratePayoutBatchResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
|
||||
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
|
||||
|
||||
// Holiday-aware shifting: a batch landing on a bank-closed Nowruz day must move to the next business day,
|
||||
// or PAYA/SATNA fails.
|
||||
var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken);
|
||||
var processingDate = await holidays.NextBusinessDay(DateOnly.FromDateTime(now), cancellationToken);
|
||||
var requireBnplSettlement = await platformConfig.GetConfig<bool>("require_bnpl_settlement_for_payout", cancellationToken);
|
||||
|
||||
var eligible = await unitOfWork.PayoutRepository.GetEligibleBookingsAsync(
|
||||
request.PeriodStart, periodEnd, now, requireBnplSettlement, cancellationToken);
|
||||
if (eligible.Count == 0)
|
||||
return OperationResult<GeneratePayoutBatchResult>.FailureResult(
|
||||
"period", "No payout-eligible bookings in this window.");
|
||||
|
||||
var batch = new NursePayoutBatch
|
||||
{
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
ProcessingDate = processingDate,
|
||||
InitiatedByAdminId = adminId
|
||||
};
|
||||
|
||||
var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
|
||||
var names = await unitOfWork.PayoutRepository.GetNurseNamesAsync(nurseIds, cancellationToken);
|
||||
|
||||
var skipped = new List<SkippedNurseDto>();
|
||||
long batchTotal = 0;
|
||||
var payoutCount = 0;
|
||||
|
||||
foreach (var group in eligible.GroupBy(e => e.NurseId).OrderBy(g => g.Key))
|
||||
{
|
||||
var nurseId = group.Key;
|
||||
var bookings = group.ToList();
|
||||
var gross = bookings.Sum(b => b.PayoutAmountIrr);
|
||||
|
||||
// First-payout gate: only a verified primary IBAN may receive a transfer. No account → skip, recorded.
|
||||
var account = await unitOfWork.PayoutRepository.GetVerifiedPrimaryAccountAsync(nurseId, cancellationToken);
|
||||
if (account is null)
|
||||
{
|
||||
skipped.Add(new SkippedNurseDto(
|
||||
nurseId, names.GetValueOrDefault(nurseId), gross.ToString(), "no_verified_primary_iban"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var clawbackApplied = await ComputeNettableClawbackAsync(nurseId, gross, cancellationToken);
|
||||
var net = gross - clawbackApplied;
|
||||
|
||||
var payout = new NursePayout
|
||||
{
|
||||
NurseId = nurseId,
|
||||
BankAccountId = account.BankAccountId,
|
||||
IbanSnapshot = account.Iban, // encrypted at rest by the EF converter on save
|
||||
GrossEarningsIrr = gross,
|
||||
ClawbackAppliedIrr = clawbackApplied,
|
||||
NetAmountIrr = net,
|
||||
Amount = net,
|
||||
BookingCount = bookings.Count
|
||||
};
|
||||
|
||||
foreach (var b in bookings)
|
||||
payout.BookingLinks.Add(new NursePayoutBookingLink
|
||||
{
|
||||
BookingId = b.BookingId,
|
||||
PayoutAmountIrr = b.PayoutAmountIrr
|
||||
});
|
||||
|
||||
batch.Payouts.Add(payout);
|
||||
batchTotal += net;
|
||||
payoutCount++;
|
||||
}
|
||||
|
||||
if (payoutCount == 0)
|
||||
return OperationResult<GeneratePayoutBatchResult>.FailureResult(
|
||||
"nurse", "No eligible nurse has a verified primary IBAN to be paid.");
|
||||
|
||||
// total_amount = Σ net_amount_irr; payout_count = COUNT(payouts) — the batch invariant, frozen here.
|
||||
batch.SetTotals(batchTotal, payoutCount);
|
||||
|
||||
await unitOfWork.PayoutRepository.AddBatchAsync(batch, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// The booking_id UNIQUE backstop: a booking was linked by a concurrent run despite the lock. Never
|
||||
// double-pay — surface a conflict rather than aborting into a half-written batch.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return OperationResult<GeneratePayoutBatchResult>.ConflictResult(
|
||||
"Another payout run already claimed one of these bookings.");
|
||||
}
|
||||
|
||||
var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync(
|
||||
batch.Id, page: 1, pageSize: Math.Max(payoutCount, 1), cancellationToken);
|
||||
|
||||
return OperationResult<GeneratePayoutBatchResult>.SuccessResult(
|
||||
new GeneratePayoutBatchResult(detail!.Batch, detail.Payouts, skipped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The clawback netting: recovers whole <c>pending</c> clawbacks (oldest first) that fit within the nurse's
|
||||
/// earnings this batch — never a negative net, never a partial recovery of a single clawback row. A clawback
|
||||
/// larger than this batch's earnings stays fully <c>pending</c> and recovers from a later, larger batch.
|
||||
/// </summary>
|
||||
private async Task<long> ComputeNettableClawbackAsync(long nurseId, long gross, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(nurseId, cancellationToken);
|
||||
|
||||
long applied = 0;
|
||||
foreach (var clawback in pending)
|
||||
{
|
||||
if (applied + clawback.AmountIrr > gross)
|
||||
break;
|
||||
applied += clawback.AmountIrr;
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
|
||||
public sealed class GeneratePayoutBatchCommandValidator : AbstractValidator<GeneratePayoutBatchCommand>
|
||||
{
|
||||
public GeneratePayoutBatchCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PeriodStart)
|
||||
.LessThanOrEqualTo(x => x.PeriodEnd)
|
||||
.WithMessage("period_start must be on or before period_end.");
|
||||
|
||||
// A payout window must be closed — never batch a future period.
|
||||
RuleFor(x => x.PeriodEnd)
|
||||
.Must(pe => pe <= DateOnly.FromDateTime(DateTime.UtcNow.Date))
|
||||
.WithMessage("period_end cannot be in the future.");
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
|
||||
/// <summary>Opens a <c>draft</c> payout batch for a window: shifts the period end + processing date off bank-closed
|
||||
/// days, selects the payout-eligible unpaid bookings, and materializes one payout per nurse (netting pending
|
||||
/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the
|
||||
/// draft batch + payouts for admin preview; no money moves until <c>process</c>.</summary>
|
||||
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
|
||||
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
|
||||
|
||||
/// <summary>Records a reconciled bank rejection on a payout. No ledger movement — a rejected transfer means no
|
||||
/// money left, so there is nothing to reverse. Re-settles the parent batch so its status reflects the failure.</summary>
|
||||
internal sealed class MarkPayoutFailedCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<MarkPayoutFailedCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(MarkPayoutFailedCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
var stub = await unitOfWork.PayoutRepository.GetTrackedPayoutAsync(request.PayoutId, cancellationToken);
|
||||
if (stub is null)
|
||||
return OperationResult<bool>.NotFoundResult("Payout not found.");
|
||||
|
||||
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(stub.BatchId, cancellationToken);
|
||||
var payout = batch!.Payouts.First(p => p.Id == request.PayoutId);
|
||||
|
||||
// A paid payout is a confirmed, irreversible transfer — it can never be marked failed.
|
||||
if (payout.Status == PayoutStatus.Paid)
|
||||
return OperationResult<bool>.ConflictResult("A paid payout cannot be marked failed.");
|
||||
// Idempotent.
|
||||
if (payout.Status == PayoutStatus.Failed)
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
|
||||
payout.MarkFailed(request.FailureReason);
|
||||
batch.RecomputeSettlement(now);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
|
||||
|
||||
public sealed class MarkPayoutFailedCommandValidator : AbstractValidator<MarkPayoutFailedCommand>
|
||||
{
|
||||
public MarkPayoutFailedCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PayoutId).GreaterThan(0);
|
||||
RuleFor(x => x.FailureReason).NotEmpty().MaximumLength(500);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
|
||||
|
||||
/// <summary>Records a reconciled bank rejection on a payout — sets <c>failed</c> with the reason. Posts <b>no</b>
|
||||
/// ledger movement (no money left the platform). Used when the rail reports a transfer bounced after submit.</summary>
|
||||
public record MarkPayoutFailedCommand(long PayoutId, string FailureReason) : IRequest<OperationResult<bool>>;
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Holidays;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
|
||||
|
||||
/// <summary>
|
||||
/// Re-submits one <c>failed</c> payout. Holiday-aware — it refuses on a bank-closed day (PAYA/SATNA would fail).
|
||||
/// On acceptance it drives the same settlement as the batch execute (ledger post + clawback netting, both
|
||||
/// idempotent) and re-settles the parent batch (<c>partially_failed → completed</c> when it was the last failure).
|
||||
/// </summary>
|
||||
internal sealed class RetryFailedPayoutCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IBankTransferProvider bankTransfer,
|
||||
IHolidayCalendar holidays,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<RetryFailedPayoutCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(RetryFailedPayoutCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
|
||||
|
||||
var stub = await unitOfWork.PayoutRepository.GetTrackedPayoutAsync(request.PayoutId, cancellationToken);
|
||||
if (stub is null)
|
||||
return OperationResult<bool>.NotFoundResult("Payout not found.");
|
||||
|
||||
// Load the full batch (tracked, with all payouts) so the retry can re-settle the batch status; EF identity
|
||||
// resolution returns the same tracked payout instance.
|
||||
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(stub.BatchId, cancellationToken);
|
||||
var payout = batch!.Payouts.First(p => p.Id == request.PayoutId);
|
||||
|
||||
// Idempotent: an already-paid payout needs no retry.
|
||||
if (payout.Status == PayoutStatus.Paid)
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
if (payout.Status != PayoutStatus.Failed)
|
||||
return OperationResult<bool>.ConflictResult("Only a failed payout can be retried.");
|
||||
|
||||
// Holiday-aware: a real PAYA/SATNA transfer won't settle on a bank-closed day (or the Friday weekend).
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
var nextOpen = await holidays.NextBusinessDay(today, cancellationToken);
|
||||
if (nextOpen != today)
|
||||
return OperationResult<bool>.FailureResult(
|
||||
"processing_date", "Banks are closed today; retry on the next business day.");
|
||||
|
||||
var satnaThreshold = (long)await platformConfig.GetConfig<decimal>("payout_satna_threshold_irr", cancellationToken);
|
||||
|
||||
var submit = await bankTransfer.SubmitPayoutBatchAsync(
|
||||
batch.Id, [PayoutSettlement.ToInstruction(payout, satnaThreshold)],
|
||||
idempotencyKey: $"payout:{payout.Id}:retry", cancellationToken);
|
||||
|
||||
var result = submit.Results.FirstOrDefault(r => r.PayoutId == payout.Id);
|
||||
if (result is null || result.Status == BankTransferStatus.Failed)
|
||||
{
|
||||
payout.MarkFailed(result?.FailureReason ?? "provider_declined");
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.FailureResult("channel", "The bank rail declined the transfer again.");
|
||||
}
|
||||
|
||||
payout.MarkSubmitted(result.TransferReference ?? $"payout:{batch.Id}:{payout.Id}");
|
||||
if (result.Status == BankTransferStatus.Paid)
|
||||
{
|
||||
payout.MarkPaid(now);
|
||||
await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
|
||||
await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
|
||||
}
|
||||
|
||||
batch.RecomputeSettlement(now);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
|
||||
|
||||
public sealed class RetryFailedPayoutCommandValidator : AbstractValidator<RetryFailedPayoutCommand>
|
||||
{
|
||||
public RetryFailedPayoutCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PayoutId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
|
||||
|
||||
/// <summary>Re-submits a single <c>failed</c> payout to the bank rail (holiday-aware — never on a bank-closed
|
||||
/// day). On success it posts the payout ledger + nets clawbacks like the first execute and re-settles the batch;
|
||||
/// idempotent on the same key so a retried retry never double-sends.</summary>
|
||||
public record RetryFailedPayoutCommand(long PayoutId) : IRequest<OperationResult<bool>>;
|
||||
@@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
|
||||
namespace Baya.Application.Features.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// The shared money-settlement steps for a paid payout, used by both <c>ExecutePayoutBatch</c> and
|
||||
/// <c>RetryFailedPayout</c> so the ledger posting + clawback netting live in exactly one place (mirroring b12's
|
||||
/// extracted <c>BookingConversion</c>). Both operations are idempotent: the ledger-exists guard blocks a second
|
||||
/// payout group, and a <c>recovered</c> clawback is never re-marked or re-posted.
|
||||
/// </summary>
|
||||
internal static class PayoutSettlement
|
||||
{
|
||||
/// <summary>Builds the rail instruction — SATNA above the config threshold, else PAYA. The tracked payout's
|
||||
/// <see cref="NursePayout.IbanSnapshot"/> is decrypted by the EF converter on load.</summary>
|
||||
public static PayoutInstruction ToInstruction(NursePayout payout, long satnaThreshold)
|
||||
=> new(payout.Id, payout.IbanSnapshot, payout.Amount,
|
||||
payout.Amount >= satnaThreshold ? BankTransferMethod.Satna : BankTransferMethod.Paya);
|
||||
|
||||
/// <summary>DEBIT <c>nurse_payable</c> / CREDIT <c>escrow_held</c> for the paid net — skipped when the payout
|
||||
/// is fully netted (net 0) or a group already exists (retry idempotency).</summary>
|
||||
public static async Task PostPayoutLedgerAsync(IUnitOfWork unitOfWork, NursePayout payout, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (payout.Amount <= 0)
|
||||
return;
|
||||
if (await unitOfWork.PayoutRepository.LedgerGroupExistsForPayoutAsync(payout.Id, cancellationToken))
|
||||
return;
|
||||
|
||||
var legs = LedgerPosting.NursePayout(payout.NurseId, payout.Amount, payout.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Realizes the payout's frozen <c>clawback_applied_irr</c>: recovers whole pending clawbacks (oldest
|
||||
/// first, matching the build's greedy cap) — marks each <c>recovered</c> + posts DEBIT <c>nurse_payable</c> /
|
||||
/// CREDIT <c>nurse_clawback_receivable</c>. Idempotent: on a retry the clawbacks are already <c>recovered</c>.</summary>
|
||||
public static async Task RecoverClawbacksAsync(IUnitOfWork unitOfWork, NursePayout payout, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (payout.ClawbackAppliedIrr <= 0)
|
||||
return;
|
||||
|
||||
var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(payout.NurseId, cancellationToken);
|
||||
var remaining = payout.ClawbackAppliedIrr;
|
||||
|
||||
foreach (var clawback in pending)
|
||||
{
|
||||
if (clawback.AmountIrr > remaining)
|
||||
break;
|
||||
|
||||
clawback.Recover(payout.Id, now);
|
||||
var legs = LedgerPosting.ClawbackRecovery(clawback.BookingId, clawback.NurseId, clawback.AmountIrr, clawback.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
|
||||
remaining -= clawback.AmountIrr;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Holidays;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
|
||||
|
||||
internal sealed class ComputeEligibleEarningsQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IHolidayCalendar holidays,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<ComputeEligibleEarningsQuery, OperationResult<PagedResult<EligibleNurseEarningsDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<EligibleNurseEarningsDto>>> Handle(
|
||||
ComputeEligibleEarningsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Preview the exact set a generate would take — the period end is holiday-shifted the same way.
|
||||
var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken);
|
||||
var requireBnplSettlement = await platformConfig.GetConfig<bool>("require_bnpl_settlement_for_payout", cancellationToken);
|
||||
|
||||
var result = await unitOfWork.PayoutRepository.GetEligiblePreviewAsync(
|
||||
request.PeriodStart, periodEnd, now, requireBnplSettlement, page, pageSize, cancellationToken);
|
||||
|
||||
return OperationResult<PagedResult<EligibleNurseEarningsDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
|
||||
|
||||
public sealed class ComputeEligibleEarningsQueryValidator : AbstractValidator<ComputeEligibleEarningsQuery>
|
||||
{
|
||||
public ComputeEligibleEarningsQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.PeriodStart)
|
||||
.LessThanOrEqualTo(x => x.PeriodEnd)
|
||||
.WithMessage("period_start must be on or before period_end.");
|
||||
|
||||
// A payout window must be closed — never preview a future period.
|
||||
RuleFor(x => x.PeriodEnd)
|
||||
.Must(pe => pe <= DateOnly.FromDateTime(DateTime.UtcNow.Date))
|
||||
.WithMessage("period_end cannot be in the future.");
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
|
||||
|
||||
/// <summary>Admin preview of the payout-eligible, unpaid earnings for a window, grouped by nurse — the dry-run
|
||||
/// before generating a batch. Only completed bookings whose dispute window has closed and that aren't already paid
|
||||
/// appear; each nurse's pending clawback is netted and a nurse without a verified primary IBAN is flagged.
|
||||
/// Paginated.</summary>
|
||||
public record ComputeEligibleEarningsQuery(DateOnly PeriodStart, DateOnly PeriodEnd, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<EligibleNurseEarningsDto>>>;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail;
|
||||
|
||||
internal sealed class GetBatchDetailQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetBatchDetailQuery, OperationResult<PayoutBatchDetailDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PayoutBatchDetailDto>> Handle(
|
||||
GetBatchDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 200 ? 50 : request.PageSize;
|
||||
|
||||
var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync(request.BatchId, page, pageSize, cancellationToken);
|
||||
return detail is null
|
||||
? OperationResult<PayoutBatchDetailDto>.NotFoundResult("Payout batch not found.")
|
||||
: OperationResult<PayoutBatchDetailDto>.SuccessResult(detail);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail;
|
||||
|
||||
/// <summary>Admin batch detail — the header plus its paginated payouts (status, net, masked IBAN + transfer
|
||||
/// reference) and the bookings each payout covers.</summary>
|
||||
public record GetBatchDetailQuery(long BatchId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PayoutBatchDetailDto>>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
|
||||
|
||||
internal sealed class GetNursePayoutHistoryQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<GetNursePayoutHistoryQuery, OperationResult<PagedResult<NursePayoutHistoryDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<NursePayoutHistoryDto>>> Handle(
|
||||
GetNursePayoutHistoryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<NursePayoutHistoryDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
|
||||
|
||||
// Tenancy: resolve the caller's own nurse profile — a caller who is not a nurse simply has no payouts.
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } id)
|
||||
return OperationResult<PagedResult<NursePayoutHistoryDto>>.SuccessResult(
|
||||
new PagedResult<NursePayoutHistoryDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.PayoutRepository.GetNurseHistoryAsync(id, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<NursePayoutHistoryDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
|
||||
|
||||
/// <summary>The signed-in nurse's own payout history (tenancy-scoped to <c>ICurrentUser</c>) — status, net,
|
||||
/// masked IBAN + transfer reference, any clawback applied, and the batch window. Feeds f12's earnings screen.</summary>
|
||||
public record GetNursePayoutHistoryQuery(int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<NursePayoutHistoryDto>>>;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches;
|
||||
|
||||
internal sealed class ListPayoutBatchesQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListPayoutBatchesQuery, OperationResult<PagedResult<PayoutBatchDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<PayoutBatchDto>>> Handle(
|
||||
ListPayoutBatchesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
|
||||
|
||||
var result = await unitOfWork.PayoutRepository.ListBatchesAsync(request.Status, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<PayoutBatchDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches;
|
||||
|
||||
/// <summary>Admin reconciliation list of payout batches — projected + paginated, optional status filter.</summary>
|
||||
public record ListPayoutBatchesQuery(string? Status = null, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<PayoutBatchDto>>>;
|
||||
@@ -0,0 +1,87 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Payouts;
|
||||
|
||||
/// <summary>One raw eligible booking the batch build consumes: which nurse earned it and this booking's payout
|
||||
/// portion (IRR). Grouped by nurse in the handler to compute <c>gross_earnings_irr</c>.</summary>
|
||||
public record EligibleBookingRow(long NurseId, long BookingId, long PayoutAmountIrr);
|
||||
|
||||
/// <summary>The eligibility preview row — per-nurse earnings for the window, the pending clawback that would be
|
||||
/// netted, the resulting net, and whether the nurse has a verified primary IBAN to receive it (a nurse without
|
||||
/// one is <b>flagged</b>, not silently dropped). Money crosses the wire as digit strings.</summary>
|
||||
public record EligibleNurseEarningsDto(
|
||||
long NurseId,
|
||||
string? NurseName,
|
||||
int BookingCount,
|
||||
string GrossEarningsIrr,
|
||||
string ClawbackAppliedIrr,
|
||||
string NetAmountIrr,
|
||||
bool HasVerifiedPrimaryIban);
|
||||
|
||||
/// <summary>A batch header — periods (holiday-shifted), totals, status, and reconciliation timestamps.
|
||||
/// Money is a digit string.</summary>
|
||||
public record PayoutBatchDto(
|
||||
long Id,
|
||||
DateOnly PeriodStart,
|
||||
DateOnly PeriodEnd,
|
||||
DateOnly ProcessingDate,
|
||||
string TotalAmount,
|
||||
int PayoutCount,
|
||||
string Status,
|
||||
int InitiatedByAdminId,
|
||||
DateTime? ProcessedAt,
|
||||
string? FailureNotes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>The booking a payout covers — with the per-booking amount and (future) session id. Money is a digit
|
||||
/// string.</summary>
|
||||
public record PayoutBookingLinkDto(long BookingId, long? SessionId, string PayoutAmountIrr);
|
||||
|
||||
/// <summary>One payout in a batch detail — the decomposed amounts, status, the <b>masked</b> IBAN + transfer
|
||||
/// reference, and the bookings it covers. Money is a digit string.</summary>
|
||||
public record PayoutDto(
|
||||
long Id,
|
||||
long NurseId,
|
||||
string? NurseName,
|
||||
string MaskedIban,
|
||||
string GrossEarningsIrr,
|
||||
string ClawbackAppliedIrr,
|
||||
string NetAmountIrr,
|
||||
string Amount,
|
||||
int BookingCount,
|
||||
string Status,
|
||||
string? TransferReference,
|
||||
DateTime? PaidAt,
|
||||
string? FailureReason,
|
||||
IReadOnlyList<PayoutBookingLinkDto> Bookings);
|
||||
|
||||
/// <summary>A batch header plus its paginated payouts — the admin reconciliation detail view.</summary>
|
||||
public record PayoutBatchDetailDto(PayoutBatchDto Batch, IReadOnlyList<PayoutDto> Payouts, int Total, int Page, int PageSize);
|
||||
|
||||
/// <summary>A nurse skipped during a batch build, with the reason — never silently dropped (the common case is
|
||||
/// "no verified primary IBAN"). Money is a digit string.</summary>
|
||||
public record SkippedNurseDto(long NurseId, string? NurseName, string GrossEarningsIrr, string Reason);
|
||||
|
||||
/// <summary>What <c>GeneratePayoutBatchCommand</c> returns — the draft batch, the materialized payouts for admin
|
||||
/// preview, and the nurses skipped (with reasons).</summary>
|
||||
public record GeneratePayoutBatchResult(
|
||||
PayoutBatchDto Batch,
|
||||
IReadOnlyList<PayoutDto> Payouts,
|
||||
IReadOnlyList<SkippedNurseDto> Skipped);
|
||||
|
||||
/// <summary>What <c>ExecutePayoutBatchCommand</c> returns — the settled batch status and per-outcome counts.</summary>
|
||||
public record ExecutePayoutBatchResult(long BatchId, string Status, int PaidCount, int FailedCount, string TotalPaid);
|
||||
|
||||
/// <summary>A nurse's own payout-history row (tenancy-scoped) — status, net, the <b>masked</b> IBAN + reference,
|
||||
/// the clawback that was applied, and the batch window it belongs to. Money crosses the wire as digit strings.</summary>
|
||||
public record NursePayoutHistoryDto(
|
||||
long Id,
|
||||
long BatchId,
|
||||
string Status,
|
||||
string GrossEarningsIrr,
|
||||
string ClawbackAppliedIrr,
|
||||
string NetAmountIrr,
|
||||
string MaskedIban,
|
||||
string? TransferReference,
|
||||
DateTime? PaidAt,
|
||||
DateOnly PeriodStart,
|
||||
DateOnly PeriodEnd);
|
||||
@@ -160,6 +160,58 @@ public static class LedgerPosting
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>payout group</b> (b13): <c>DEBIT nurse_payable / CREDIT escrow_held</c> for the amount actually
|
||||
/// transferred to the nurse, under one fresh <see cref="LedgerEntry.TransactionGroupId"/>. Draining the
|
||||
/// <c>nurse_payable</c> accrual to a real bank transfer is the one irreversible money-out step; the balance
|
||||
/// (the signed sum over <c>nurse_payable</c> legs) drops by exactly what was paid. Posted once per payout —
|
||||
/// the payout status machine + the <c>nurse_payout_booking_links</c> UNIQUE make a retried execute a no-op.
|
||||
/// The clawback netted into the payout is <b>not</b> a leg here: the receivable was already booked by b11 and
|
||||
/// is cleared by marking the <c>nurse_clawbacks</c> row <c>recovered</c> (the net amount is simply lower).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> NursePayout(
|
||||
long nurseId,
|
||||
long amountIrr,
|
||||
long payoutId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
if (amountIrr <= 0)
|
||||
throw new InvalidOperationException("A payout ledger group requires a positive amount.");
|
||||
|
||||
var group = Guid.NewGuid();
|
||||
return
|
||||
[
|
||||
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, amountIrr, nurseId, null, LedgerSourceRefType.NursePayout, payoutId, createdAt),
|
||||
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, amountIrr, null, null, LedgerSourceRefType.NursePayout, payoutId, createdAt)
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The clawback <b>recovery</b> netting (b13): when a payout withholds a nurse's <c>pending</c> clawback, the
|
||||
/// withheld earnings clear the receivable — <c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> for
|
||||
/// the recovered amount, under one group. Together with the payout group (which debits <c>nurse_payable</c>
|
||||
/// by the paid net), this drains the nurse's <c>nurse_payable</c> by the full gross and zeroes the receivable,
|
||||
/// so the derived balances reconcile. Posted once per recovered clawback (the <c>recovered</c> status makes a
|
||||
/// retry a no-op).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> ClawbackRecovery(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long amountIrr,
|
||||
long clawbackId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
if (amountIrr <= 0)
|
||||
throw new InvalidOperationException("A clawback-recovery group requires a positive amount.");
|
||||
|
||||
var group = Guid.NewGuid();
|
||||
return
|
||||
[
|
||||
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt),
|
||||
Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt)
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The clawback <b>write-off</b> correction: <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> for
|
||||
/// the amount, when an admin declares the receivable uncollectable. A new balancing group — never an edit.
|
||||
@@ -181,7 +233,7 @@ public static class LedgerPosting
|
||||
|
||||
private static LedgerEntry Leg(
|
||||
Guid group, string account, string direction, long amount, long? nurse,
|
||||
long bookingId, string sourceType, long sourceId, DateTime createdAt) => new()
|
||||
long? bookingId, string sourceType, long sourceId, DateTime createdAt) => new()
|
||||
{
|
||||
TransactionGroupId = group,
|
||||
AccountType = account,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// One row per nurse per batch — the exact amount transferred, the frozen IBAN snapshot, and the bank transfer
|
||||
/// reference for reconciliation. The nurse's earnings for the window are <see cref="GrossEarningsIrr"/>; any
|
||||
/// <c>pending</c> clawback the nurse owes back is netted into <see cref="ClawbackAppliedIrr"/> so the platform
|
||||
/// never overpays a nurse with a receivable.
|
||||
/// <para>
|
||||
/// <b>Invariant:</b> <see cref="NetAmountIrr"/> = <see cref="GrossEarningsIrr"/> −
|
||||
/// <see cref="ClawbackAppliedIrr"/>; all amounts ≥ 0; <see cref="NetAmountIrr"/> ≥ 0 (a clawback exceeding
|
||||
/// earnings nets to <b>zero this batch</b>, the remainder staying <c>pending</c> for the next — never a negative
|
||||
/// transfer). <see cref="Amount"/> is what actually moves; it equals <see cref="NetAmountIrr"/> on success.
|
||||
/// Money is IRR <c>BIGINT</c>, no floats. <see cref="IbanSnapshot"/> is encrypted at rest and frozen at build
|
||||
/// time from the nurse's verified primary account. Paid-ness is derived from a
|
||||
/// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NursePayout : BaseEntity<long>
|
||||
{
|
||||
public long BatchId { get; set; }
|
||||
public NursePayoutBatch Batch { get; set; } = null!;
|
||||
|
||||
public long NurseId { get; set; }
|
||||
|
||||
/// <summary>The verified primary account paid (FK <c>nurse_bank_accounts</c>).</summary>
|
||||
public long BankAccountId { get; set; }
|
||||
|
||||
/// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.</summary>
|
||||
public string IbanSnapshot { get; set; } = null!;
|
||||
|
||||
/// <summary>Σ eligible booking payouts for the window (IRR).</summary>
|
||||
public long GrossEarningsIrr { get; set; }
|
||||
|
||||
/// <summary>Pending clawbacks netted this batch (IRR, ≥ 0, capped at <see cref="GrossEarningsIrr"/>).</summary>
|
||||
public long ClawbackAppliedIrr { get; set; }
|
||||
|
||||
/// <summary>Derived: <see cref="GrossEarningsIrr"/> − <see cref="ClawbackAppliedIrr"/> (IRR, ≥ 0).</summary>
|
||||
public long NetAmountIrr { get; set; }
|
||||
|
||||
/// <summary>Actually transferred net (IRR) — equals <see cref="NetAmountIrr"/> on success.</summary>
|
||||
public long Amount { get; set; }
|
||||
|
||||
public int BookingCount { get; set; }
|
||||
|
||||
/// <summary>Guarded — a <see cref="PayoutStatus"/> code, mutated only through the mark-* methods.</summary>
|
||||
public string Status { get; private set; } = PayoutStatus.Pending;
|
||||
|
||||
/// <summary>The bank track id (PAYA/SATNA), set at submit — kept for reconciliation.</summary>
|
||||
public string? TransferReference { get; private set; }
|
||||
|
||||
public DateTime? PaidAt { get; private set; }
|
||||
|
||||
public string? FailureReason { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<NursePayoutBookingLink> BookingLinks { get; set; } = new List<NursePayoutBookingLink>();
|
||||
|
||||
public bool CanTransitionTo(string target) => PayoutStatusTransitions.CanTransition(Status, target);
|
||||
|
||||
private void Transition(string target)
|
||||
{
|
||||
if (!PayoutStatusTransitions.CanTransition(Status, target))
|
||||
throw new InvalidOperationException($"Illegal payout transition {Status} → {target}.");
|
||||
Status = target;
|
||||
}
|
||||
|
||||
/// <summary>Records the bank track id and transitions <c>pending|failed → submitted</c>. Clears any prior
|
||||
/// failure so a retry starts clean.</summary>
|
||||
public void MarkSubmitted(string transferReference)
|
||||
{
|
||||
Transition(PayoutStatus.Submitted);
|
||||
TransferReference = transferReference;
|
||||
FailureReason = null;
|
||||
}
|
||||
|
||||
/// <summary>Confirms the transfer and transitions <c>submitted → paid</c> — the irreversible movement.</summary>
|
||||
public void MarkPaid(DateTime now)
|
||||
{
|
||||
Transition(PayoutStatus.Paid);
|
||||
PaidAt = now;
|
||||
}
|
||||
|
||||
/// <summary>Records a rail rejection and transitions to <c>failed</c> (from pending or submitted).</summary>
|
||||
public void MarkFailed(string reason)
|
||||
{
|
||||
Transition(PayoutStatus.Failed);
|
||||
FailureReason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// A weekly aggregation of amounts owed for completed, payout-eligible, unpaid bookings — the operational unit of
|
||||
/// the payout run, matching the PAYA settlement cycle. An admin (later, a scheduled job) opens a batch; it
|
||||
/// materializes one <see cref="NursePayout"/> per nurse with earnings in the window, then submits them all to the
|
||||
/// bank rail in one go.
|
||||
/// <para>
|
||||
/// <b>Holiday-aware:</b> <see cref="PeriodEnd"/> and <see cref="ProcessingDate"/> are shifted off bank-closed days
|
||||
/// (via <c>IHolidayCalendar</c>) to the next business day — a batch landing on a multi-day Nowruz closure would
|
||||
/// otherwise fail, since PAYA/SATNA does not settle on closed days. <b>Invariant:</b>
|
||||
/// <see cref="TotalAmount"/> = Σ(<c>nurse_payouts.net_amount_irr</c>) and <see cref="PayoutCount"/> =
|
||||
/// COUNT(payouts) — set by the handler when the rows are materialized and asserted by a verified invariant.
|
||||
/// Money is IRR <c>BIGINT</c>, no floats.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NursePayoutBatch : BaseEntity<long>
|
||||
{
|
||||
public DateOnly PeriodStart { get; set; }
|
||||
|
||||
/// <summary>Window end — shifted off <c>is_bank_closed</c> days to the next business day.</summary>
|
||||
public DateOnly PeriodEnd { get; set; }
|
||||
|
||||
/// <summary>The date the transfers are submitted — shifted off <c>is_bank_closed</c> days.</summary>
|
||||
public DateOnly ProcessingDate { get; set; }
|
||||
|
||||
/// <summary>Σ(<c>net_amount_irr</c>) across this batch's payouts (IRR). Set at materialization.</summary>
|
||||
public long TotalAmount { get; set; }
|
||||
|
||||
public int PayoutCount { get; set; }
|
||||
|
||||
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
|
||||
public string Status { get; private set; } = PayoutBatchStatus.Draft;
|
||||
|
||||
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
|
||||
public int InitiatedByAdminId { get; set; }
|
||||
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
|
||||
public string? FailureNotes { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<NursePayout> Payouts { get; set; } = new List<NursePayout>();
|
||||
|
||||
public bool CanTransitionTo(string target) => PayoutBatchTransitions.CanTransition(Status, target);
|
||||
|
||||
/// <summary>Applies a guarded status change and, on a settling edge, stamps <see cref="ProcessedAt"/>. Reaching
|
||||
/// an illegal edge is a programming error (callers pre-check), so it fails fast rather than corrupting state.</summary>
|
||||
public void TransitionTo(string target, DateTime now, string? failureNotes = null)
|
||||
{
|
||||
if (!PayoutBatchTransitions.CanTransition(Status, target))
|
||||
throw new InvalidOperationException($"Illegal payout-batch transition {Status} → {target}.");
|
||||
|
||||
Status = target;
|
||||
if (target is PayoutBatchStatus.Completed or PayoutBatchStatus.PartiallyFailed or PayoutBatchStatus.Failed)
|
||||
ProcessedAt = now;
|
||||
if (failureNotes is not null)
|
||||
FailureNotes = failureNotes;
|
||||
}
|
||||
|
||||
/// <summary>Freezes the batch totals when the payouts are materialized (the CHECK-mirroring invariant).</summary>
|
||||
public void SetTotals(long totalAmount, int payoutCount)
|
||||
{
|
||||
TotalAmount = totalAmount;
|
||||
PayoutCount = payoutCount;
|
||||
}
|
||||
|
||||
/// <summary>Re-derives the batch's terminal status from its payouts after an execute or a retry: all paid →
|
||||
/// <c>completed</c>; some failed but some paid → <c>partially_failed</c>; all failed → <c>failed</c>. A no-op
|
||||
/// when the resulting edge isn't allowed from the current status (e.g. already <c>partially_failed</c> with a
|
||||
/// still-failed row). Requires the <see cref="Payouts"/> collection to be loaded.</summary>
|
||||
public void RecomputeSettlement(DateTime now)
|
||||
{
|
||||
var anyFailed = Payouts.Any(p => p.Status == PayoutStatus.Failed);
|
||||
var anyPaid = Payouts.Any(p => p.Status == PayoutStatus.Paid);
|
||||
|
||||
var target = !anyFailed
|
||||
? PayoutBatchStatus.Completed
|
||||
: anyPaid ? PayoutBatchStatus.PartiallyFailed : PayoutBatchStatus.Failed;
|
||||
|
||||
if (CanTransitionTo(target))
|
||||
TransitionTo(target, now, target == PayoutBatchStatus.Failed ? "All payouts failed at the bank rail." : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// The join from a payout to the specific booking it covers — and the platform's strongest correctness feature:
|
||||
/// <see cref="BookingId"/> carries a <b>UNIQUE</b> index so a booking can be paid in <b>exactly one</b> payout
|
||||
/// across all batches, ever. A duplicate insert <i>is</i> the already-paid signal (the handler catches it, treats
|
||||
/// the booking as not-eligible, and continues) — the structural anti-double-pay guard, not just a pre-check.
|
||||
/// <para>
|
||||
/// The <see cref="BookingId"/> UNIQUE is <b>unconditional</b> (not filtered on soft-delete): the link is a
|
||||
/// permanent record of an irreversible transfer, so even a removed row must never re-open a booking for
|
||||
/// re-payment. <see cref="SessionId"/> is nullable for a future per-session accrual model; today one link per
|
||||
/// booking carries the whole booking payout. Money is IRR <c>BIGINT</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NursePayoutBookingLink : BaseEntity<long>
|
||||
{
|
||||
public long PayoutId { get; set; }
|
||||
|
||||
/// <summary><b>UNIQUE</b> (unconditional) across every batch — the hard one-payout-per-booking guard.</summary>
|
||||
public long BookingId { get; set; }
|
||||
|
||||
/// <summary>Set only when paying a per-session accrual; null for whole-booking payment.</summary>
|
||||
public long? SessionId { get; set; }
|
||||
|
||||
/// <summary>The portion of this booking (or session) paid in this payout (IRR).</summary>
|
||||
public long PayoutAmountIrr { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>nurse_payout_batches.status</c> code set. A batch opens in <see cref="Draft"/> (materialized
|
||||
/// but not yet submitted to the bank), moves to <see cref="Processing"/> when the transfers are submitted, and
|
||||
/// ends <see cref="Completed"/> (all paid), <see cref="PartiallyFailed"/> (some rejected — retryable), or
|
||||
/// <see cref="Failed"/> (the whole submit failed). Persisted as these stable snake_case codes; the allowed edges
|
||||
/// live in <see cref="PayoutBatchTransitions"/>.
|
||||
/// </summary>
|
||||
public static class PayoutBatchStatus
|
||||
{
|
||||
/// <summary>Materialized (payouts + links built) but not yet submitted — the admin preview state.</summary>
|
||||
public const string Draft = "draft";
|
||||
|
||||
/// <summary>The bank submit is in flight / partially applied.</summary>
|
||||
public const string Processing = "processing";
|
||||
|
||||
/// <summary>At least one payout was rejected by the rail; the rest paid. Retry the failed rows.</summary>
|
||||
public const string PartiallyFailed = "partially_failed";
|
||||
|
||||
/// <summary>Every payout in the batch was paid. Terminal.</summary>
|
||||
public const string Completed = "completed";
|
||||
|
||||
/// <summary>The whole submit failed (e.g. the rail was unreachable / a closed day). Terminal for the run.</summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// The allowed-edge table for the <see cref="PayoutBatchStatus"/> machine. A batch opens in
|
||||
/// <see cref="PayoutBatchStatus.Draft"/>, is submitted (<see cref="PayoutBatchStatus.Processing"/>), then settles
|
||||
/// to a terminal outcome. <see cref="PayoutBatchStatus.PartiallyFailed"/> is re-enterable: a retry that clears the
|
||||
/// last failed payout flips it to <see cref="PayoutBatchStatus.Completed"/>.
|
||||
/// </summary>
|
||||
public static class PayoutBatchTransitions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
|
||||
new Dictionary<string, IReadOnlyCollection<string>>
|
||||
{
|
||||
[PayoutBatchStatus.Draft] = [PayoutBatchStatus.Processing, PayoutBatchStatus.Failed],
|
||||
[PayoutBatchStatus.Processing] =
|
||||
[PayoutBatchStatus.Completed, PayoutBatchStatus.PartiallyFailed, PayoutBatchStatus.Failed],
|
||||
// A retry can clear the last failed payout and complete the batch.
|
||||
[PayoutBatchStatus.PartiallyFailed] = [PayoutBatchStatus.Completed, PayoutBatchStatus.PartiallyFailed],
|
||||
[PayoutBatchStatus.Completed] = [],
|
||||
[PayoutBatchStatus.Failed] = []
|
||||
};
|
||||
|
||||
public static bool CanTransition(string from, string to)
|
||||
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>nurse_payouts.status</c> code set — a <b>forward-only</b> lifecycle that (with the
|
||||
/// <c>nurse_payout_booking_links.booking_id</c> UNIQUE and the batch lock) makes a retried execute never
|
||||
/// double-send an irreversible transfer. A payout is materialized <see cref="Pending"/>, moves to
|
||||
/// <see cref="Submitted"/> when the bank accepts the instruction, and to <see cref="Paid"/> once the transfer
|
||||
/// track id is confirmed; a rail rejection lands it in <see cref="Failed"/>, from which a retry re-submits.
|
||||
/// Persisted as these stable snake_case codes; the allowed edges live in <see cref="PayoutStatusTransitions"/>.
|
||||
/// </summary>
|
||||
public static class PayoutStatus
|
||||
{
|
||||
/// <summary>Materialized into the draft batch but not yet submitted to the rail.</summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>The bank accepted the transfer instruction (a PAYA/SATNA track id was issued).</summary>
|
||||
public const string Submitted = "submitted";
|
||||
|
||||
/// <summary>The transfer is confirmed paid — an irreversible IBAN movement. Terminal on success.</summary>
|
||||
public const string Paid = "paid";
|
||||
|
||||
/// <summary>The rail rejected the transfer; a retry re-submits the same instruction.</summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Baya.Domain.Entities.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// The forward-only allowed-edge table for the <see cref="PayoutStatus"/> machine (mirrors
|
||||
/// <c>BnplTransitions</c>/<c>RefundTransitions</c>). Every write goes through <see cref="NursePayout"/>'s
|
||||
/// cohesive mark-* methods, which assert the edge here — so a replayed execute that would re-drive an
|
||||
/// already-<see cref="PayoutStatus.Paid"/> row is rejected before it can re-send an irreversible transfer or
|
||||
/// re-post the ledger. <see cref="PayoutStatus.Failed"/> is the only re-enterable state (a retry re-submits).
|
||||
/// </summary>
|
||||
public static class PayoutStatusTransitions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
|
||||
new Dictionary<string, IReadOnlyCollection<string>>
|
||||
{
|
||||
[PayoutStatus.Pending] = [PayoutStatus.Submitted, PayoutStatus.Failed],
|
||||
[PayoutStatus.Submitted] = [PayoutStatus.Paid, PayoutStatus.Failed],
|
||||
// A rejected transfer can be re-submitted.
|
||||
[PayoutStatus.Failed] = [PayoutStatus.Submitted],
|
||||
// Terminal on success — no outgoing edge (paid is an irreversible transfer).
|
||||
[PayoutStatus.Paid] = []
|
||||
};
|
||||
|
||||
public static bool CanTransition(string from, string to)
|
||||
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
|
||||
}
|
||||
@@ -39,6 +39,18 @@ public class NurseClawback : BaseEntity<long>
|
||||
|
||||
public bool IsPending => Status == ClawbackStatus.Pending;
|
||||
|
||||
/// <summary>Netted out of a payout batch (b13): records the recovering payout + resolution. The balancing
|
||||
/// <c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> posting is the payout handler's job; this
|
||||
/// records the workflow outcome. Only a <c>pending</c> clawback can be recovered.</summary>
|
||||
public void Recover(long payoutId, DateTime now)
|
||||
{
|
||||
if (Status != ClawbackStatus.Pending)
|
||||
throw new InvalidOperationException($"Only a pending clawback can be recovered (was {Status}).");
|
||||
Status = ClawbackStatus.Recovered;
|
||||
RecoveredInPayoutId = payoutId;
|
||||
ResolvedAt = now;
|
||||
}
|
||||
|
||||
/// <summary>Admin declares the receivable uncollectable. The balancing <c>bad_debt</c> posting is the
|
||||
/// handler's job; this records the workflow outcome.</summary>
|
||||
public void WriteOff(string notes, DateTime now)
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class AdminPayoutsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Generate_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/api/v1/admin_payouts/batches",
|
||||
new { periodStart = "2020-01-01", periodEnd = "2020-01-31" });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_InvalidPeriod_Returns400()
|
||||
{
|
||||
var admin = factory.CreateClient();
|
||||
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902401");
|
||||
|
||||
// period_start after period_end
|
||||
var response = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches",
|
||||
new { periodStart = "2026-06-30", periodEnd = "2026-06-01" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_then_process_pays_the_eligible_nurse()
|
||||
{
|
||||
var admin = factory.CreateClient();
|
||||
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902402");
|
||||
await SeedEligibleNurseBookingAsync(factory, "09131902403");
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd");
|
||||
var generate = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches",
|
||||
new { periodStart = "2020-01-01", periodEnd = today });
|
||||
Assert.Equal(HttpStatusCode.OK, generate.StatusCode);
|
||||
|
||||
var data = await AuthTestClient.ReadDataAsync(generate);
|
||||
var batchId = data.GetProperty("batch").GetProperty("id").GetInt64();
|
||||
Assert.Equal("draft", data.GetProperty("batch").GetProperty("status").GetString());
|
||||
Assert.True(data.GetProperty("payouts").GetArrayLength() >= 1);
|
||||
|
||||
var process = await admin.PostAsJsonAsync($"/api/v1/admin_payouts/batches/{batchId}/process", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, process.StatusCode);
|
||||
var processed = await AuthTestClient.ReadDataAsync(process);
|
||||
Assert.Equal("completed", processed.GetProperty("status").GetString());
|
||||
Assert.True(processed.GetProperty("paidCount").GetInt32() >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_AsAdmin_ReturnsPagedEnvelope()
|
||||
{
|
||||
var admin = factory.CreateClient();
|
||||
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902404");
|
||||
|
||||
var response = await admin.GetAsync("/api/v1/admin_payouts/batches?page=1&pageSize=20");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.True(data.GetProperty("total").GetInt32() >= 0);
|
||||
}
|
||||
|
||||
/// <summary>Seeds a completed, dispute-window-closed booking for a nurse who has a verified primary IBAN, so
|
||||
/// the batch has exactly one eligible payout. Returns the nurse's phone (usable to authenticate as the nurse).</summary>
|
||||
internal static async Task SeedEligibleNurseBookingAsync(BayaApiFactory factory, string nursePhone)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
|
||||
|
||||
var customerUser = new User { UserName = $"cust_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", IsActive = true };
|
||||
db.Users.Add(customerUser);
|
||||
db.SaveChanges();
|
||||
var customer = new CustomerProfile { UserId = customerUser.Id };
|
||||
db.Set<CustomerProfile>().Add(customer);
|
||||
db.SaveChanges();
|
||||
|
||||
// The nurse must be a real Identity user so the same phone can authenticate for the history test.
|
||||
var nurseUser = await userManager.GetUserByPhoneNumber(nursePhone);
|
||||
if (nurseUser is null)
|
||||
{
|
||||
await userManager.CreateUser(new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = nursePhone, Gender = "female", Name = "زهرا", FamilyName = "احمدی" });
|
||||
nurseUser = await userManager.GetUserByPhoneNumber(nursePhone);
|
||||
}
|
||||
var nurse = new NurseProfile { UserId = nurseUser!.Id };
|
||||
nurse.MarkVerified();
|
||||
nurse.SetAcceptingBookings(true);
|
||||
db.Set<NurseProfile>().Add(nurse);
|
||||
db.SaveChanges();
|
||||
|
||||
db.Set<NurseBankAccount>().Add(new NurseBankAccount
|
||||
{
|
||||
NurseId = nurse.Id, BankName = "ملی", AccountHolderName = "زهرا", Iban = $"IR{Random.Shared.Next(100000, 999999)}0000000000000000",
|
||||
IbanHash = $"hash-{nurse.Id}", IsPrimary = true, IsVerified = true, MatchedNationalId = true,
|
||||
AccountHolderFromBank = "زهرا", OwnershipVendorRef = "mock"
|
||||
});
|
||||
|
||||
var province = new Province { NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
|
||||
db.Set<Province>().Add(province);
|
||||
db.SaveChanges();
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
|
||||
db.Set<City>().Add(city);
|
||||
var category = new ServiceCategory { NameFa = "س", NameEn = "E", SortOrder = 1, IsActive = true };
|
||||
db.Set<ServiceCategory>().Add(category);
|
||||
db.SaveChanges();
|
||||
|
||||
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پ", FirstName = "ح", LastName = "ر", Gender = "male", IsActive = true };
|
||||
db.Set<Patient>().Add(patient);
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CustomerId = customer.Id, CityId = city.Id, Title = "خ", AddressLine = "خیابان",
|
||||
PostalCode = "1111111111", RecipientName = "ع", RecipientPhone = customerUser.PhoneNumber, IsPrimary = true
|
||||
};
|
||||
db.Set<CustomerAddress>().Add(address);
|
||||
db.SaveChanges();
|
||||
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 10_000_000, PriceUnit = "per_day",
|
||||
SessionCount = null, DisplayName = "مراقبت", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
|
||||
};
|
||||
db.Set<NurseServiceVariant>().Add(variant);
|
||||
db.SaveChanges();
|
||||
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id,
|
||||
CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any,
|
||||
RequestedDate = new DateOnly(2020, 1, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "n", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
db.Set<BookingRequest>().Add(request);
|
||||
db.SaveChanges();
|
||||
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
db.SaveChanges();
|
||||
|
||||
var completedAt = new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc);
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = request.Id, CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id,
|
||||
VariantId = variant.Id, CustomerAddressId = address.Id, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
|
||||
GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m,
|
||||
NursePayoutAmount = 8_500_000, SessionCount = 1,
|
||||
ScheduledDate = new DateOnly(2020, 1, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
|
||||
};
|
||||
booking.TransitionTo(BookingStatus.Confirmed, completedAt);
|
||||
booking.TransitionTo(BookingStatus.InProgress, completedAt);
|
||||
booking.TransitionTo(BookingStatus.Completed, completedAt);
|
||||
booking.SetDisputeWindow(new DateTime(2020, 1, 5, 0, 0, 0, DateTimeKind.Utc)); // long past
|
||||
db.Set<BookingEntity>().Add(booking);
|
||||
db.SaveChanges();
|
||||
|
||||
var legs = LedgerPosting.CardCapture(booking.Id, nurse.Id, 10_000_000, 1_500_000, 8_500_000, 0, completedAt);
|
||||
db.Set<LedgerEntry>().AddRange(legs);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class NursePayoutsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task History_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/nurse_payouts/history");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_AsNurse_ReturnsOwnPaidPayout_WithMaskedIban()
|
||||
{
|
||||
const string nursePhone = "09131903401";
|
||||
|
||||
// Pay the nurse through the real admin flow, then read the nurse's own history.
|
||||
var admin = factory.CreateClient();
|
||||
await AdminTestClient.AuthenticateAsync(factory, admin, "09131903402");
|
||||
await AdminPayoutsApiTests.SeedEligibleNurseBookingAsync(factory, nursePhone);
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd");
|
||||
var generate = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches",
|
||||
new { periodStart = "2020-01-01", periodEnd = today });
|
||||
Assert.Equal(HttpStatusCode.OK, generate.StatusCode);
|
||||
var batchId = (await AuthTestClient.ReadDataAsync(generate)).GetProperty("batch").GetProperty("id").GetInt64();
|
||||
var process = await admin.PostAsJsonAsync($"/api/v1/admin_payouts/batches/{batchId}/process", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, process.StatusCode);
|
||||
|
||||
var nurse = factory.CreateClient();
|
||||
var tokens = await AuthTestClient.LoginAsync(factory, nurse, nursePhone);
|
||||
AuthTestClient.UseBearer(nurse, tokens.GetProperty("accessToken").GetString()!);
|
||||
|
||||
var response = await nurse.GetAsync("/api/v1/nurse_payouts/history?page=1&pageSize=20");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.True(data.GetProperty("total").GetInt32() >= 1);
|
||||
var item = data.GetProperty("items")[0];
|
||||
Assert.Equal("paid", item.GetProperty("status").GetString());
|
||||
Assert.Equal("8500000", item.GetProperty("netAmountIrr").GetString());
|
||||
Assert.Contains("•", item.GetProperty("maskedIban").GetString()!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
|
||||
using Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
|
||||
using Baya.Application.Contracts.Holidays;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Test.Foundation.Payouts;
|
||||
|
||||
public class PayoutHandlerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTime Past = new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); // dispute window closed
|
||||
private static readonly DateTime Future = new(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); // still open
|
||||
private static readonly DateOnly PeriodStart = new(2026, 6, 1);
|
||||
private static readonly DateOnly PeriodEnd = new(2026, 6, 30);
|
||||
|
||||
private static GeneratePayoutBatchCommand GenCmd => new(PeriodStart, PeriodEnd);
|
||||
|
||||
private static GeneratePayoutBatchCommandHandler Gen(PayoutsTestHost host, IHolidayCalendar? holidays = null)
|
||||
=> new(host.UnitOfWork, host.Lock(), holidays ?? host.Holidays(), host.Config(), host.Clock(Now), host.AsAdmin());
|
||||
|
||||
private static ExecutePayoutBatchCommandHandler Exec(PayoutsTestHost host, Baya.Application.Contracts.Payments.IBankTransferProvider? bank = null)
|
||||
=> new(host.UnitOfWork, host.Lock(), bank ?? host.Bank(), host.Config(), host.Clock(Now));
|
||||
|
||||
private static ComputeEligibleEarningsQueryHandler Preview(PayoutsTestHost host)
|
||||
=> new(host.UnitOfWork, host.Holidays(), host.Config(), host.Clock(Now));
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_includes_only_closed_window_and_flags_missing_iban()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurseA = host.SeedNurse();
|
||||
var nurseB = host.SeedNurse(verifiedIban: null);
|
||||
host.SeedCompletedBooking(nurseA, Past); // eligible
|
||||
host.SeedCompletedBooking(nurseA, Future); // future window — excluded
|
||||
host.SeedCompletedBooking(nurseA, Past, disputed: true); // disputed — excluded
|
||||
host.SeedCompletedBooking(nurseB, Past); // eligible earnings but no verified IBAN
|
||||
|
||||
var result = await Preview(host).Handle(new ComputeEligibleEarningsQuery(PeriodStart, PeriodEnd), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var items = result.Result.Items;
|
||||
Assert.Equal(2, items.Count);
|
||||
|
||||
var a = items.Single(x => x.NurseId == nurseA);
|
||||
Assert.Equal(1, a.BookingCount); // only the one closed-window, non-disputed booking
|
||||
Assert.Equal("8500000", a.GrossEarningsIrr);
|
||||
Assert.True(a.HasVerifiedPrimaryIban);
|
||||
|
||||
var b = items.Single(x => x.NurseId == nurseB);
|
||||
Assert.False(b.HasVerifiedPrimaryIban);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_materializes_one_payout_per_nurse_and_nets_clawback()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurse = host.SeedNurse();
|
||||
host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000); // eligible, payout 8.5M
|
||||
var prior = host.SeedCompletedBooking(nurse, Past); // gets a refund below → excluded from earnings
|
||||
host.SeedPendingClawback(nurse, prior, 2_000_000);
|
||||
|
||||
var result = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(PayoutBatchStatus.Draft, result.Result.Batch.Status);
|
||||
var payout = Assert.Single(result.Result.Payouts);
|
||||
Assert.Equal("8500000", payout.GrossEarningsIrr);
|
||||
Assert.Equal("2000000", payout.ClawbackAppliedIrr);
|
||||
Assert.Equal("6500000", payout.NetAmountIrr);
|
||||
Assert.Equal("6500000", payout.Amount);
|
||||
Assert.Equal(1, payout.BookingCount);
|
||||
Assert.Equal("6500000", result.Result.Batch.TotalAmount);
|
||||
Assert.Equal(1, result.Result.Batch.PayoutCount);
|
||||
Assert.EndsWith("0123", payout.MaskedIban);
|
||||
Assert.Contains("•", payout.MaskedIban);
|
||||
Assert.Empty(result.Result.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_skips_nurse_without_verified_primary_iban_with_reason()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurseOk = host.SeedNurse();
|
||||
var nurseNoIban = host.SeedNurse(verifiedIban: null);
|
||||
host.SeedCompletedBooking(nurseOk, Past);
|
||||
host.SeedCompletedBooking(nurseNoIban, Past);
|
||||
|
||||
var result = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(result.Result.Payouts);
|
||||
var skipped = Assert.Single(result.Result.Skipped);
|
||||
Assert.Equal(nurseNoIban, skipped.NurseId);
|
||||
Assert.Equal("no_verified_primary_iban", skipped.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Double_pay_guard_second_generate_does_not_reselect_linked_bookings()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurse = host.SeedNurse();
|
||||
host.SeedCompletedBooking(nurse, Past);
|
||||
|
||||
var first = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
Assert.True(first.IsSuccess);
|
||||
|
||||
var second = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
Assert.False(second.IsSuccess); // the booking_id UNIQUE link excludes it — nothing left to pay
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_posts_balanced_payout_ledger_and_drains_payable()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurse = host.SeedNurse();
|
||||
host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000);
|
||||
|
||||
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
var batchId = gen.Result.Batch.Id;
|
||||
var before = host.NursePayableBalance(nurse);
|
||||
|
||||
var exec = await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
|
||||
|
||||
Assert.True(exec.IsSuccess);
|
||||
Assert.Equal(PayoutBatchStatus.Completed, exec.Result.Status);
|
||||
Assert.Equal(1, exec.Result.PaidCount);
|
||||
|
||||
var after = host.NursePayableBalance(nurse);
|
||||
Assert.Equal(8_500_000, before - after);
|
||||
|
||||
var legs = PayoutLegs(host);
|
||||
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Debit));
|
||||
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit));
|
||||
Assert.Equal(
|
||||
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
|
||||
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
|
||||
|
||||
var payout = host.Db.Set<NursePayout>().AsNoTracking().Single();
|
||||
Assert.Equal(PayoutStatus.Paid, payout.Status);
|
||||
Assert.NotNull(payout.TransferReference);
|
||||
Assert.NotNull(payout.PaidAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_recovers_clawback_and_posts_recovery_leg()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurse = host.SeedNurse();
|
||||
host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000);
|
||||
var prior = host.SeedCompletedBooking(nurse, Past);
|
||||
var clawbackId = host.SeedPendingClawback(nurse, prior, 2_000_000);
|
||||
|
||||
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
await Exec(host).Handle(new ExecutePayoutBatchCommand(gen.Result.Batch.Id), CancellationToken.None);
|
||||
|
||||
var recovery = host.Db.Set<LedgerEntry>().AsNoTracking()
|
||||
.Where(l => l.SourceRefType == LedgerSourceRefType.Clawback && l.SourceRefId == clawbackId).ToList();
|
||||
Assert.Equal(2_000_000, Leg(recovery, LedgerAccountType.NursePayable, LedgerDirection.Debit));
|
||||
Assert.Equal(2_000_000, Leg(recovery, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit));
|
||||
|
||||
var clawback = host.Db.Set<NurseClawback>().AsNoTracking().Single(c => c.Id == clawbackId);
|
||||
Assert.Equal(ClawbackStatus.Recovered, clawback.Status);
|
||||
Assert.NotNull(clawback.RecoveredInPayoutId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reprocess_is_idempotent_no_second_ledger_group()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurse = host.SeedNurse();
|
||||
host.SeedCompletedBooking(nurse, Past);
|
||||
|
||||
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
var batchId = gen.Result.Batch.Id;
|
||||
|
||||
await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
|
||||
var countAfterFirst = PayoutLegs(host).Count;
|
||||
|
||||
var second = await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
|
||||
Assert.True(second.IsSuccess);
|
||||
Assert.Equal(PayoutBatchStatus.Completed, second.Result.Status);
|
||||
Assert.Equal(countAfterFirst, PayoutLegs(host).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Holiday_shifts_period_end_and_processing_date()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurse = host.SeedNurse();
|
||||
host.SeedCompletedBooking(nurse, Past);
|
||||
|
||||
var shiftedEnd = new DateOnly(2026, 7, 2);
|
||||
var shiftedProcessing = new DateOnly(2026, 7, 4);
|
||||
var holidays = host.Holidays(
|
||||
(PeriodEnd, shiftedEnd),
|
||||
(DateOnly.FromDateTime(Now.UtcDateTime), shiftedProcessing));
|
||||
|
||||
var result = await Gen(host, holidays).Handle(GenCmd, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(shiftedEnd, result.Result.Batch.PeriodEnd);
|
||||
Assert.Equal(shiftedProcessing, result.Result.Batch.ProcessingDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partial_failure_then_retry_completes_the_batch()
|
||||
{
|
||||
using var host = new PayoutsTestHost();
|
||||
var nurseOk = host.SeedNurse("IR000000000000000000000001");
|
||||
var nurseFail = host.SeedNurse("IR000000000000000000000999");
|
||||
host.SeedCompletedBooking(nurseOk, Past);
|
||||
host.SeedCompletedBooking(nurseFail, Past);
|
||||
|
||||
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
|
||||
var batchId = gen.Result.Batch.Id;
|
||||
|
||||
var failingBank = host.Bank(failIban: "IR000000000000000000000999");
|
||||
var exec = await Exec(host, failingBank).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
|
||||
|
||||
Assert.Equal(PayoutBatchStatus.PartiallyFailed, exec.Result.Status);
|
||||
Assert.Equal(1, exec.Result.PaidCount);
|
||||
Assert.Equal(1, exec.Result.FailedCount);
|
||||
|
||||
var failedPayout = host.Db.Set<NursePayout>().AsNoTracking().Single(p => p.Status == PayoutStatus.Failed);
|
||||
|
||||
var retry = new RetryFailedPayoutCommandHandler(
|
||||
host.UnitOfWork, host.Lock(), host.Bank(), host.Holidays(), host.Config(), host.Clock(Now));
|
||||
var retried = await retry.Handle(new RetryFailedPayoutCommand(failedPayout.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(retried.IsSuccess);
|
||||
var batch = await host.Db.Set<NursePayoutBatch>().AsNoTracking().FirstAsync(b => b.Id == batchId);
|
||||
Assert.Equal(PayoutBatchStatus.Completed, batch.Status);
|
||||
}
|
||||
|
||||
private static List<LedgerEntry> PayoutLegs(PayoutsTestHost host)
|
||||
=> host.Db.Set<LedgerEntry>().AsNoTracking()
|
||||
.Where(l => l.SourceRefType == LedgerSourceRefType.NursePayout).ToList();
|
||||
|
||||
private static long Leg(IReadOnlyList<LedgerEntry> legs, string account, string direction)
|
||||
=> legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Holidays;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
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.CrossCutting.Seams;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Test.Foundation.Payouts;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained SQLite host exercising the real EF model (schema, the net-split CHECK, the booking_id UNIQUE
|
||||
/// link, encrypted iban_snapshot, query filters) for the b13 payout engine. Seeds a customer + bookable nurses and
|
||||
/// can create completed, dispute-window-closed bookings, verified primary bank accounts, and pending clawbacks so
|
||||
/// a test can drive the real handlers against the real <see cref="UnitOfWork"/> with substituted seams.
|
||||
/// </summary>
|
||||
public sealed class PayoutsTestHost : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
public ApplicationDbContext Db { get; }
|
||||
public UnitOfWork UnitOfWork { get; }
|
||||
|
||||
public long CustomerId { get; }
|
||||
public int AdminUserId { get; }
|
||||
private readonly long _cityId;
|
||||
private readonly long _categoryId;
|
||||
private readonly long _patientId;
|
||||
private readonly long _addressId;
|
||||
private int _phoneSeq = 100;
|
||||
|
||||
public PayoutsTestHost()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
Db.Database.EnsureCreated();
|
||||
UnitOfWork = new UnitOfWork(Db);
|
||||
|
||||
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<Province>().Add(province);
|
||||
Db.SaveChanges();
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<City>().Add(city);
|
||||
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
|
||||
Db.Set<ServiceCategory>().Add(category);
|
||||
Db.SaveChanges();
|
||||
_cityId = city.Id;
|
||||
_categoryId = category.Id;
|
||||
|
||||
var adminUser = new User { UserName = "admin1", PhoneNumber = NextPhone(), Gender = "male", Name = "ادمین", FamilyName = "سیستم", IsActive = true };
|
||||
Db.Users.Add(adminUser);
|
||||
Db.SaveChanges();
|
||||
AdminUserId = adminUser.Id;
|
||||
|
||||
var customerUser = new User { UserName = "cust1", PhoneNumber = NextPhone(), Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
|
||||
Db.Users.Add(customerUser);
|
||||
Db.SaveChanges();
|
||||
var customer = new CustomerProfile { UserId = customerUser.Id };
|
||||
Db.Set<CustomerProfile>().Add(customer);
|
||||
Db.SaveChanges();
|
||||
CustomerId = customer.Id;
|
||||
|
||||
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
|
||||
Db.Set<Patient>().Add(patient);
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
|
||||
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
|
||||
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
|
||||
};
|
||||
Db.Set<CustomerAddress>().Add(address);
|
||||
Db.SaveChanges();
|
||||
_patientId = patient.Id;
|
||||
_addressId = address.Id;
|
||||
}
|
||||
|
||||
private string NextPhone() => $"0912000{++_phoneSeq:0000}";
|
||||
|
||||
/// <summary>Seeds a bookable nurse. When <paramref name="verifiedIban"/> is set, also seeds a verified primary
|
||||
/// bank account (is_primary + is_verified + matched_national_id) with the given IBAN.</summary>
|
||||
public long SeedNurse(string? verifiedIban = "IR000000000000000000000123")
|
||||
{
|
||||
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = NextPhone(), Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
|
||||
Db.Users.Add(nurseUser);
|
||||
Db.SaveChanges();
|
||||
var nurse = new NurseProfile { UserId = nurseUser.Id };
|
||||
nurse.MarkVerified();
|
||||
nurse.SetAcceptingBookings(true);
|
||||
Db.Set<NurseProfile>().Add(nurse);
|
||||
Db.SaveChanges();
|
||||
|
||||
if (verifiedIban is not null)
|
||||
{
|
||||
var account = new NurseBankAccount
|
||||
{
|
||||
NurseId = nurse.Id, BankName = "بانک ملی", AccountHolderName = "زهرا احمدی",
|
||||
Iban = verifiedIban, IbanHash = $"hash-{nurse.Id}", IsPrimary = true, IsVerified = true,
|
||||
MatchedNationalId = true, AccountHolderFromBank = "زهرا احمدی", OwnershipVendorRef = "mock"
|
||||
};
|
||||
Db.Set<NurseBankAccount>().Add(account);
|
||||
Db.SaveChanges();
|
||||
}
|
||||
|
||||
return nurse.Id;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a completed booking with the given dispute-window close. Amounts satisfy
|
||||
/// <c>gross = commission + payout</c>. When <paramref name="disputed"/> is set the booking is moved to
|
||||
/// <c>disputed</c> (payout-ineligible).</summary>
|
||||
public long SeedCompletedBooking(
|
||||
long nurseId, DateTime disputeWindowEndsAt, long gross = 10_000_000, long commission = 1_500_000, bool disputed = false)
|
||||
{
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = nurseId, ServiceCategoryId = _categoryId, Price = gross, PriceUnit = "per_day",
|
||||
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
|
||||
};
|
||||
Db.Set<NurseServiceVariant>().Add(variant);
|
||||
Db.SaveChanges();
|
||||
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = CustomerId, NurseId = nurseId, PatientId = _patientId, VariantId = variant.Id,
|
||||
CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any,
|
||||
RequestedDate = new DateOnly(2026, 6, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
Db.Set<BookingRequest>().Add(request);
|
||||
Db.SaveChanges();
|
||||
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Db.SaveChanges();
|
||||
|
||||
var confirmedAt = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = nurseId, PatientId = _patientId,
|
||||
VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
|
||||
GrossPriceIrr = gross, BalinyaarCommissionIrr = commission, PlatformFeeRate = 0.15m,
|
||||
NursePayoutAmount = gross - commission, SessionCount = 1,
|
||||
ScheduledDate = new DateOnly(2026, 6, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
|
||||
};
|
||||
booking.TransitionTo(BookingStatus.Confirmed, confirmedAt);
|
||||
booking.TransitionTo(BookingStatus.InProgress, confirmedAt);
|
||||
booking.TransitionTo(BookingStatus.Completed, confirmedAt);
|
||||
booking.SetDisputeWindow(disputeWindowEndsAt);
|
||||
if (disputed)
|
||||
booking.TransitionTo(BookingStatus.Disputed, confirmedAt);
|
||||
Db.Set<BookingEntity>().Add(booking);
|
||||
Db.SaveChanges();
|
||||
|
||||
// The capture accrual so nurse_payable starts positive and the payout drains it to zero.
|
||||
var legs = LedgerPosting.CardCapture(booking.Id, nurseId, gross, commission, gross - commission, 0, confirmedAt);
|
||||
Db.Set<LedgerEntry>().AddRange(legs);
|
||||
Db.SaveChanges();
|
||||
|
||||
return booking.Id;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a pending clawback the nurse owes back (opened by a prior post-payout refund in b11).</summary>
|
||||
public long SeedPendingClawback(long nurseId, long bookingId, long amount)
|
||||
{
|
||||
// A minimal refund row to satisfy the clawback's required refund_id FK.
|
||||
var refund = new Refund
|
||||
{
|
||||
PaymentTransactionId = SeedThrowawayTransaction(bookingId), BookingId = bookingId, RequestedByCustomerId = CustomerId,
|
||||
Amount = amount, PlatformFeeRefundedIrr = 0, NursePayoutRefundedIrr = amount, RefundPercentage = 1m,
|
||||
RefundChannel = RefundChannel.PspCard
|
||||
};
|
||||
Db.Set<Refund>().Add(refund);
|
||||
Db.SaveChanges();
|
||||
|
||||
var clawback = new NurseClawback { NurseId = nurseId, BookingId = bookingId, RefundId = refund.Id, AmountIrr = amount };
|
||||
Db.Set<NurseClawback>().Add(clawback);
|
||||
Db.SaveChanges();
|
||||
return clawback.Id;
|
||||
}
|
||||
|
||||
private long SeedThrowawayTransaction(long bookingId)
|
||||
{
|
||||
var gateway = new PaymentGateway { ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 };
|
||||
Db.Set<PaymentGateway>().Add(gateway);
|
||||
Db.SaveChanges();
|
||||
var nurseBookingRequestId = Db.Set<BookingEntity>().Where(b => b.Id == bookingId).Select(b => b.BookingRequestId).First();
|
||||
var txn = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = nurseBookingRequestId, CustomerId = CustomerId, GatewayId = gateway.Id,
|
||||
Amount = 10_000_000, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}"
|
||||
};
|
||||
txn.MarkSucceeded(bookingId, "ok", null);
|
||||
Db.Set<PaymentTransaction>().Add(txn);
|
||||
Db.SaveChanges();
|
||||
return txn.Id;
|
||||
}
|
||||
|
||||
public ICurrentUser AsAdmin(int? userId = null)
|
||||
{
|
||||
var u = Substitute.For<ICurrentUser>();
|
||||
u.UserId.Returns(userId ?? AdminUserId);
|
||||
u.Roles.Returns(new[] { RoleNames.Admin });
|
||||
return u;
|
||||
}
|
||||
|
||||
public IDateTimeProvider Clock(DateTimeOffset now)
|
||||
{
|
||||
var c = Substitute.For<IDateTimeProvider>();
|
||||
c.UtcNow.Returns(now);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>Identity holiday calendar (no shift) unless <paramref name="shifts"/> maps a closed day to its
|
||||
/// next business day.</summary>
|
||||
public IHolidayCalendar Holidays(params (DateOnly Closed, DateOnly Next)[] shifts)
|
||||
{
|
||||
var h = Substitute.For<IHolidayCalendar>();
|
||||
h.NextBusinessDay(Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var d = ci.Arg<DateOnly>();
|
||||
foreach (var (closed, next) in shifts)
|
||||
if (closed == d) return next;
|
||||
return d;
|
||||
});
|
||||
h.IsBankClosed(Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => shifts.Any(s => s.Closed == ci.Arg<DateOnly>()));
|
||||
return h;
|
||||
}
|
||||
|
||||
public IPlatformConfig Config(long satnaThresholdIrr = 1_000_000_000, bool requireBnplSettlement = false)
|
||||
{
|
||||
var cfg = Substitute.For<IPlatformConfig>();
|
||||
cfg.GetConfig<decimal>("payout_satna_threshold_irr", Arg.Any<CancellationToken>()).Returns(satnaThresholdIrr);
|
||||
cfg.GetConfig<bool>("require_bnpl_settlement_for_payout", Arg.Any<CancellationToken>()).Returns(requireBnplSettlement);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
public IBankTransferProvider Bank(bool forceFailure = false, string failIban = "")
|
||||
=> new MockBankTransferProvider(Options.Create(new SeamOptions
|
||||
{
|
||||
BankTransfer = new BankTransferOptions { ForceFailure = forceFailure, FailIban = failIban }
|
||||
}));
|
||||
|
||||
public IDistributedLock Lock() => new NoOpLock();
|
||||
|
||||
public IReadOnlyList<LedgerEntry> LedgerFor(long? bookingId, long? nurseId = null)
|
||||
=> Db.Set<LedgerEntry>().AsNoTracking()
|
||||
.Where(l => (bookingId == null || l.BookingId == bookingId) && (nurseId == null || l.NurseId == nurseId))
|
||||
.OrderBy(l => l.Id).ToList();
|
||||
|
||||
public long NursePayableBalance(long nurseId)
|
||||
=> Db.Set<LedgerEntry>().AsNoTracking()
|
||||
.Where(l => l.AccountType == LedgerAccountType.NursePayable && l.NurseId == nurseId)
|
||||
.Sum(l => l.Direction == LedgerDirection.Credit ? l.AmountIrr : -l.AmountIrr);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private sealed class NoOpLock : IDistributedLock
|
||||
{
|
||||
public ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IAsyncDisposable>(new Handle());
|
||||
|
||||
private sealed class Handle : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user