refinement phase 6
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
|
||||
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
|
||||
using Baya.Application.Features.Refunds.Queries.ListRefunds;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
@@ -37,4 +39,20 @@ public sealed class AdminRefundsController(ISender sender) : BaseController
|
||||
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
|
||||
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
// Reconciliation confirmed the customer cash-back for a processing BNPL/manual refund — settle it (posts the
|
||||
// deferred refund_payable ↔ escrow_held clearing). Idempotent.
|
||||
[HttpPost("{id}/[action]")]
|
||||
[ProducesOkApiResponseType<RefundSettlementResult>]
|
||||
public async Task<IActionResult> ConfirmSettlement(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ConfirmRefundSettlementCommand(id), cancellationToken));
|
||||
|
||||
// Reconciliation reported the customer cash-back did not land — fail the processing refund (no ledger moves).
|
||||
[HttpPost("{id}/[action]")]
|
||||
[ProducesOkApiResponseType<RefundSettlementResult>]
|
||||
public async Task<IActionResult> MarkFailed(long id, MarkRefundFailedBody body, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new MarkRefundSettlementFailedCommand(id, body.Reason), cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>The mark-failed body (the id comes from the route).</summary>
|
||||
public record MarkRefundFailedBody(string? Reason);
|
||||
|
||||
@@ -29,6 +29,15 @@ public interface IRefundRepository
|
||||
/// <summary>Tracked pending clawback — for the admin write-off. Null when absent or already resolved.</summary>
|
||||
Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked refund row — for the settlement confirm/fail transition of a <c>processing</c> refund.
|
||||
/// Null when absent.</summary>
|
||||
Task<Refund?> GetTrackedRefundByIdAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The id of the <c>processing</c> refund for a captured transaction — the BNPL cash-back callback
|
||||
/// resolves the refund it should settle from the same <c>payment_transaction</c> the revert was created
|
||||
/// against. Null when there is no in-flight (processing) refund for it.</summary>
|
||||
Task<long?> GetProcessingRefundIdForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
|
||||
|
||||
+36
-11
@@ -7,8 +7,10 @@ using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
|
||||
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
|
||||
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
|
||||
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -22,7 +24,7 @@ internal sealed class HandleBnplCallbackCommandHandler(
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>>
|
||||
{
|
||||
private enum CallbackAction { None, Verify, Settle, Revert }
|
||||
private enum CallbackAction { None, Verify, Settle, Revert, RefundConfirmed }
|
||||
|
||||
public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -85,7 +87,7 @@ internal sealed class HandleBnplCallbackCommandHandler(
|
||||
return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
|
||||
}
|
||||
|
||||
var dispatched = await DispatchAsync(action, bnpl.Id, verification.ExternalEventId, rawBody, cancellationToken);
|
||||
var dispatched = await DispatchAsync(action, bnpl, verification.ExternalEventId, rawBody, cancellationToken);
|
||||
|
||||
if (dispatched)
|
||||
webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now);
|
||||
@@ -96,25 +98,48 @@ internal sealed class HandleBnplCallbackCommandHandler(
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private async Task<bool> DispatchAsync(CallbackAction action, long bnplTransactionId, string eventId, string rawBody, CancellationToken cancellationToken)
|
||||
private async Task<bool> DispatchAsync(CallbackAction action, BnplTransaction bnpl, string eventId, string rawBody, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = action switch
|
||||
switch (action)
|
||||
{
|
||||
CallbackAction.Verify => (await sender.Send(new VerifyBnplOrderCommand(bnplTransactionId, rawBody), cancellationToken)).IsSuccess,
|
||||
CallbackAction.Settle => (await sender.Send(new SettleBnplOrderCommand(bnplTransactionId, $"bnpl-settle-{bnplTransactionId}-{eventId}", rawBody), cancellationToken)).IsSuccess,
|
||||
CallbackAction.Revert => (await sender.Send(new RevertBnplOrderCommand(bnplTransactionId, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess,
|
||||
_ => false
|
||||
};
|
||||
return result;
|
||||
case CallbackAction.Verify:
|
||||
return (await sender.Send(new VerifyBnplOrderCommand(bnpl.Id, rawBody), cancellationToken)).IsSuccess;
|
||||
case CallbackAction.Settle:
|
||||
return (await sender.Send(new SettleBnplOrderCommand(bnpl.Id, $"bnpl-settle-{bnpl.Id}-{eventId}", rawBody), cancellationToken)).IsSuccess;
|
||||
case CallbackAction.Revert:
|
||||
return (await sender.Send(new RevertBnplOrderCommand(bnpl.Id, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess;
|
||||
case CallbackAction.RefundConfirmed:
|
||||
{
|
||||
// The provider confirmed the customer cash-back for an earlier revert. Resolve the processing
|
||||
// refund created against this order's payment_transaction and settle it (posts the deferred
|
||||
// refund_payable ↔ escrow_held clearing). No in-flight refund → retryable (not a silent success).
|
||||
var refundId = await unitOfWork.RefundRepository.GetProcessingRefundIdForTransactionAsync(bnpl.PaymentTransactionId, cancellationToken);
|
||||
if (refundId is null)
|
||||
return false;
|
||||
return (await sender.Send(new ConfirmRefundSettlementCommand(refundId.Value), cancellationToken)).IsSuccess;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static CallbackAction ResolveAction(string eventType)
|
||||
{
|
||||
// A "revert/refund confirmed/completed/settled" (or "cashback") event closes an EARLIER revert's async
|
||||
// customer cash-back — distinct from the order-level "settle". Checked first so "revert_settled" doesn't
|
||||
// fall through to the order-settle branch below.
|
||||
var isRevertScoped = eventType.Contains("revert", StringComparison.OrdinalIgnoreCase)
|
||||
|| eventType.Contains("refund", StringComparison.OrdinalIgnoreCase);
|
||||
if (eventType.Contains("cashback", StringComparison.OrdinalIgnoreCase)
|
||||
|| (isRevertScoped && (eventType.Contains("complet", StringComparison.OrdinalIgnoreCase)
|
||||
|| eventType.Contains("confirm", StringComparison.OrdinalIgnoreCase)
|
||||
|| eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))))
|
||||
return CallbackAction.RefundConfirmed;
|
||||
if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))
|
||||
return CallbackAction.Settle;
|
||||
if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase))
|
||||
return CallbackAction.Verify;
|
||||
if (eventType.Contains("revert", StringComparison.OrdinalIgnoreCase) || eventType.Contains("refund", StringComparison.OrdinalIgnoreCase))
|
||||
if (isRevertScoped)
|
||||
return CallbackAction.Revert;
|
||||
return CallbackAction.None;
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
|
||||
|
||||
/// <summary>
|
||||
/// Closes the money loop left open by a BNPL/manual refund. A card refund clears its
|
||||
/// <c>refund_payable ↔ escrow_held</c> leg immediately at create time; a BNPL/manual refund sits in
|
||||
/// <c>processing</c> until the provider/bank confirms the customer actually got the cash — at which point this
|
||||
/// posts the deferred clearing so the ledger reconciles with the bank. Runs under the same
|
||||
/// <c>booking:{id}:refund</c> lock as <c>CreateRefundCommand</c>, and re-reads the refund row <b>inside</b> the
|
||||
/// lock so a racing/replayed confirm sees the committed (already-succeeded) state and no-ops instead of
|
||||
/// double-clearing.
|
||||
/// </summary>
|
||||
internal sealed class ConfirmRefundSettlementCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<ConfirmRefundSettlementCommand, OperationResult<RefundSettlementResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RefundSettlementResult>> Handle(ConfirmRefundSettlementCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// A no-tracking read first to key the lock (and carry the customer's user id for the notification).
|
||||
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
|
||||
if (projection is null)
|
||||
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync($"booking:{projection.Refund.BookingId}:refund", cancellationToken);
|
||||
|
||||
// Load the tracked row INSIDE the lock so its status reflects committed truth even under contention.
|
||||
var refund = await unitOfWork.RefundRepository.GetTrackedRefundByIdAsync(request.RefundId, cancellationToken);
|
||||
if (refund is null)
|
||||
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
|
||||
|
||||
// Idempotent: an already-succeeded refund (a replayed callback, or a card refund that cleared at create
|
||||
// time) is a no-op success — the clearing must never post twice.
|
||||
if (refund.Status == RefundStatus.Succeeded)
|
||||
return Result(refund);
|
||||
|
||||
if (refund.Status != RefundStatus.Processing)
|
||||
return OperationResult<RefundSettlementResult>.ConflictResult(
|
||||
$"Only a processing refund can be settled (was {refund.Status}).");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
refund.MarkSucceededReconciled(now);
|
||||
|
||||
// The customer cash-back is confirmed → clear refund_payable ↔ escrow_held for the refunded total, in the
|
||||
// same commit as the status flip so the ledger can never observe a settled refund with an unposted clearing.
|
||||
var clearing = LedgerPosting.RefundPayableClearing(refund.BookingId, refund.Amount, refund.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await NotifyCustomerAsync(projection.CustomerUserId, refund, cancellationToken);
|
||||
|
||||
return Result(refund);
|
||||
}
|
||||
|
||||
private async Task NotifyCustomerAsync(int customerUserId, Refund refund, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { booking_id = refund.BookingId, refund_id = refund.Id, refund.Status });
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(customerUserId, "refund_completed", "Refund completed",
|
||||
"Your refund has been completed and the funds are on their way to you.", payload),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static OperationResult<RefundSettlementResult> Result(Refund refund)
|
||||
=> OperationResult<RefundSettlementResult>.SuccessResult(
|
||||
new RefundSettlementResult(refund.Id, refund.BookingId, refund.Status, refund.ProcessedAt));
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation confirmed the customer cash-back for a <c>processing</c> BNPL/manual refund: transitions it
|
||||
/// <c>processing → succeeded</c>, stamps the settled instant, and posts the deferred
|
||||
/// <c>refund_payable ↔ escrow_held</c> clearing in the same commit. Reached two ways — the admin
|
||||
/// <c>confirm_settlement</c> action and the BNPL provider cash-back callback. Idempotent: a replay against an
|
||||
/// already-succeeded refund is a no-op success (the clearing is never posted twice).
|
||||
/// </summary>
|
||||
public record ConfirmRefundSettlementCommand(long RefundId) : IRequest<OperationResult<RefundSettlementResult>>;
|
||||
+10
-4
@@ -103,15 +103,21 @@ internal sealed class CreateRefundCommandHandler(
|
||||
RefundPercentageApplied = context.CancellationRefundPercentage
|
||||
};
|
||||
|
||||
// Execute the channel (external, behind its seam) BEFORE persisting, so a provider refusal leaves the
|
||||
// refund row failed with no ledger. The idempotency key makes a retried channel call a no-op.
|
||||
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);
|
||||
|
||||
// Crash-window fix: persist the refund as an approved INTENT (committed) BEFORE the external channel call,
|
||||
// so a crash between provider success and our commit leaves a reconcilable record rather than a silently
|
||||
// executed refund with no row. This is the same claim-first / execute-second shape the webhook handler uses.
|
||||
await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Execute the channel (external, behind its seam) against the already-persisted row. The idempotency key
|
||||
// makes a retried channel call a no-op; a provider refusal marks the row failed with no ledger.
|
||||
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);
|
||||
|
||||
if (!executed)
|
||||
{
|
||||
await unitOfWork.CommitAsync(); // persist the failed transition; no ledger is posted
|
||||
return OperationResult<CreateRefundResult>.FailureResult("channel", "The refund channel refused the reversal.");
|
||||
}
|
||||
|
||||
// Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is
|
||||
// irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed.
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
|
||||
|
||||
/// <summary>
|
||||
/// Fails a <c>processing</c> BNPL/manual refund whose customer cash-back reconciliation did not succeed. No ledger
|
||||
/// moves (the clearing was never posted for a processing refund). Runs under the <c>booking:{id}:refund</c> lock
|
||||
/// and re-reads the row inside it so a racing confirm/fail is serialized and a replay no-ops.
|
||||
/// </summary>
|
||||
internal sealed class MarkRefundSettlementFailedCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<MarkRefundSettlementFailedCommand, OperationResult<RefundSettlementResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RefundSettlementResult>> Handle(MarkRefundSettlementFailedCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
|
||||
if (projection is null)
|
||||
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync($"booking:{projection.Refund.BookingId}:refund", cancellationToken);
|
||||
|
||||
var refund = await unitOfWork.RefundRepository.GetTrackedRefundByIdAsync(request.RefundId, cancellationToken);
|
||||
if (refund is null)
|
||||
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
|
||||
|
||||
// Idempotent: already-failed is a no-op success.
|
||||
if (refund.Status == RefundStatus.Failed)
|
||||
return Result(refund);
|
||||
|
||||
if (refund.Status != RefundStatus.Processing)
|
||||
return OperationResult<RefundSettlementResult>.ConflictResult(
|
||||
$"Only a processing refund can be failed (was {refund.Status}).");
|
||||
|
||||
refund.MarkFailed(request.Reason ?? "settlement_failed");
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return Result(refund);
|
||||
}
|
||||
|
||||
private static OperationResult<RefundSettlementResult> Result(Refund refund)
|
||||
=> OperationResult<RefundSettlementResult>.SuccessResult(
|
||||
new RefundSettlementResult(refund.Id, refund.BookingId, refund.Status, refund.ProcessedAt));
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
|
||||
|
||||
/// <summary>
|
||||
/// The counterpart to <c>ConfirmRefundSettlementCommand</c>: reconciliation reports that the BNPL/manual customer
|
||||
/// cash-back for a <c>processing</c> refund did <b>not</b> land. Transitions <c>processing → failed</c> and records
|
||||
/// the reason; posts no ledger (nothing cleared). Idempotent: a replay against an already-failed refund is a
|
||||
/// no-op success.
|
||||
/// </summary>
|
||||
public record MarkRefundSettlementFailedCommand(long RefundId, string? Reason) : IRequest<OperationResult<RefundSettlementResult>>;
|
||||
@@ -66,6 +66,15 @@ public record RefundStatusDto(
|
||||
/// to <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
|
||||
public record RefundStatusProjection(int CustomerUserId, RefundStatusDto Refund);
|
||||
|
||||
/// <summary>What the settlement transition commands return — the refund's identity, its booking, the resulting
|
||||
/// status (<c>succeeded</c>/<c>failed</c>) and when it was stamped settled. Reconciliation of a BNPL/manual
|
||||
/// refund's async customer cash-back.</summary>
|
||||
public record RefundSettlementResult(
|
||||
long RefundId,
|
||||
long BookingId,
|
||||
string Status,
|
||||
DateTime? CompletedAt);
|
||||
|
||||
/// <summary>What <c>CreateRefundCommand</c> returns — the created refund's identity, channel, terminal-ish
|
||||
/// status, decomposed legs and (BNPL) ETA, and whether it opened a clawback. Money is a digit string.</summary>
|
||||
public record CreateRefundResult(
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Baya.Domain.Entities.Payouts;
|
||||
/// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NursePayout : BaseEntity<long>
|
||||
public class NursePayout : BaseEntity<long>, IAuditable
|
||||
{
|
||||
public long BatchId { get; set; }
|
||||
public NursePayoutBatch Batch { get; set; } = null!;
|
||||
@@ -28,7 +28,9 @@ public class NursePayout : BaseEntity<long>
|
||||
/// <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>
|
||||
/// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.
|
||||
/// <see cref="AuditRedactedAttribute"/> so the audit-log diff records a redaction marker, never the plaintext IBAN.</summary>
|
||||
[AuditRedacted]
|
||||
public string IbanSnapshot { get; set; } = null!;
|
||||
|
||||
/// <summary>Σ eligible booking payouts for the window (IRR).</summary>
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace Baya.Domain.Entities.Payouts;
|
||||
/// Money is IRR <c>BIGINT</c>, no floats.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NursePayoutBatch : BaseEntity<long>
|
||||
public class NursePayoutBatch : BaseEntity<long>, IAuditable
|
||||
{
|
||||
public DateOnly PeriodStart { get; set; }
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Baya.Domain.Entities.Refunds;
|
||||
/// when a payout batch nets the clawback out — <c>nurse_payouts</c> arrives in b13.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NurseClawback : BaseEntity<long>
|
||||
public class NurseClawback : BaseEntity<long>, IAuditable
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
public long BookingId { get; set; }
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Baya.Domain.Entities.Refunds;
|
||||
/// differ.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Refund : BaseEntity<long>
|
||||
public class Refund : BaseEntity<long>, IAuditable
|
||||
{
|
||||
/// <summary>The captured transaction being reversed (N:1 — a transaction may have several partial refunds).</summary>
|
||||
public long PaymentTransactionId { get; set; }
|
||||
@@ -101,8 +101,11 @@ public class Refund : BaseEntity<long>
|
||||
ExpectedCustomerRefundEta = expectedCustomerRefundEta;
|
||||
}
|
||||
|
||||
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual).</summary>
|
||||
public void MarkSucceededAsync(DateTime now)
|
||||
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual) —
|
||||
/// the admin <c>confirm_settlement</c> or the provider cash-back callback. Stamps <see cref="ProcessedAt"/>
|
||||
/// (the settled-at instant); the caller posts the <c>refund_payable ↔ escrow_held</c> clearing in the same
|
||||
/// commit. (Not async — the name only mirrored the BNPL/manual "async settlement" concept.)</summary>
|
||||
public void MarkSucceededReconciled(DateTime now)
|
||||
{
|
||||
Transition(RefundStatus.Succeeded);
|
||||
ProcessedAt = now;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Baya.Domain.Entities.Verification;
|
||||
/// reversed on suspension). The legacy <c>nurse_profiles.verification_status</c> column was deliberately
|
||||
/// cut — never reintroduce a second copy of this state.
|
||||
/// </summary>
|
||||
public class NurseVerification : BaseEntity<long>
|
||||
public class NurseVerification : BaseEntity<long>, IAuditable
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
|
||||
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
|
||||
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
|
||||
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
|
||||
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
|
||||
// (19) refund_ticket_required — retired in refinement-phase-6. b15 unconditionally auto-opens a refund
|
||||
// ticket, so the config-gated rule had no consumer left; the seed row is deleted by that migration.
|
||||
(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)."),
|
||||
|
||||
+6
-2
@@ -1,5 +1,6 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Invoices;
|
||||
using Baya.Domain.Entities.PartnerCenters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -8,8 +9,8 @@ namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
|
||||
/// <summary>
|
||||
/// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential
|
||||
/// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the
|
||||
/// commission line only. <c>partner_center_id</c> is a nullable column with <b>no FK</b> — <c>partner_centers</c>
|
||||
/// is a forward-dep on b15.
|
||||
/// commission line only. <c>partner_center_id</c> is a nullable FK to <c>partner.PartnerCenters</c> (b15 shipped
|
||||
/// the table; refinement-phase-6 added the constraint + index, <c>ON DELETE NO ACTION</c>).
|
||||
/// </summary>
|
||||
internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
|
||||
{
|
||||
@@ -26,8 +27,11 @@ internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
|
||||
|
||||
builder.HasIndex(i => i.InvoiceNumber).IsUnique();
|
||||
builder.HasIndex(i => i.BookingId).IsUnique();
|
||||
builder.HasIndex(i => i.PartnerCenterId);
|
||||
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired();
|
||||
// Forward-dep FK to the b15 partner_centers table (refinement-phase-6). Optional (nullable); NO ACTION.
|
||||
builder.HasOne<PartnerCenter>().WithMany().HasForeignKey(i => i.PartnerCenterId).OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
builder.HasQueryFilter(i => i.DeletedAt == null);
|
||||
}
|
||||
|
||||
+8
-2
@@ -1,5 +1,6 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
@@ -8,8 +9,9 @@ namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was
|
||||
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable columns + indexes now
|
||||
/// with <b>no FK</b> — <c>nurse_payouts</c> is a forward-dep on b13, which sets the values and wires the FKs.
|
||||
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable FKs to
|
||||
/// <c>payouts.NursePayouts</c> (b13 shipped the table and sets the values; refinement-phase-6 added the
|
||||
/// constraints, <c>ON DELETE NO ACTION</c>).
|
||||
/// </summary>
|
||||
internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback>
|
||||
{
|
||||
@@ -30,6 +32,10 @@ internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawba
|
||||
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired();
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired();
|
||||
builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired();
|
||||
// Forward-dep FKs to the b13 payouts table (refinement-phase-6). Both optional (nullable); NO ACTION so
|
||||
// deleting a payout never cascades into the receivable rows. Two distinct FKs to the same principal.
|
||||
builder.HasOne<NursePayout>().WithMany().HasForeignKey(c => c.OriginalPayoutId).OnDelete(DeleteBehavior.NoAction);
|
||||
builder.HasOne<NursePayout>().WithMany().HasForeignKey(c => c.RecoveredInPayoutId).OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
builder.HasQueryFilter(c => c.DeletedAt == null);
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,5 +1,6 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -10,8 +11,8 @@ namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
|
||||
/// <summary>
|
||||
/// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation
|
||||
/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a
|
||||
/// single-row constraint). <c>ticket_id</c> is a nullable column + index now with <b>no FK</b> — the
|
||||
/// <c>tickets</c> table is a forward-dep on b15, which wires the real FK target.
|
||||
/// single-row constraint). <c>ticket_id</c> is a nullable FK to <c>messaging.Tickets</c> (b15 shipped the table;
|
||||
/// refinement-phase-6 added the constraint, <c>ON DELETE NO ACTION</c>).
|
||||
/// </summary>
|
||||
internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
|
||||
{
|
||||
@@ -38,12 +39,14 @@ internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
|
||||
builder.HasIndex(r => r.BookingId);
|
||||
builder.HasIndex(r => r.RequestedByCustomerId);
|
||||
builder.HasIndex(r => r.Status);
|
||||
// Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist).
|
||||
builder.HasIndex(r => r.TicketId);
|
||||
|
||||
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired();
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
|
||||
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired();
|
||||
// Forward-dep FK to the b15 tickets table (refinement-phase-6). Optional (nullable); NO ACTION so deleting
|
||||
// a ticket never cascades into money rows.
|
||||
builder.HasOne<Ticket>().WithMany().HasForeignKey(r => r.TicketId).OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
builder.HasQueryFilter(r => r.DeletedAt == null);
|
||||
}
|
||||
|
||||
+6048
File diff suppressed because it is too large
Load Diff
+98
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefinementPhase6MoneyFks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
keyColumn: "Id",
|
||||
keyValue: 19L);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_PartnerCenterId",
|
||||
schema: "payments",
|
||||
table: "Invoices",
|
||||
column: "PartnerCenterId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Invoices_PartnerCenters_PartnerCenterId",
|
||||
schema: "payments",
|
||||
table: "Invoices",
|
||||
column: "PartnerCenterId",
|
||||
principalSchema: "partner",
|
||||
principalTable: "PartnerCenters",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NurseClawbacks_NursePayouts_OriginalPayoutId",
|
||||
schema: "payments",
|
||||
table: "NurseClawbacks",
|
||||
column: "OriginalPayoutId",
|
||||
principalSchema: "payouts",
|
||||
principalTable: "NursePayouts",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NurseClawbacks_NursePayouts_RecoveredInPayoutId",
|
||||
schema: "payments",
|
||||
table: "NurseClawbacks",
|
||||
column: "RecoveredInPayoutId",
|
||||
principalSchema: "payouts",
|
||||
principalTable: "NursePayouts",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Refunds_Tickets_TicketId",
|
||||
schema: "payments",
|
||||
table: "Refunds",
|
||||
column: "TicketId",
|
||||
principalSchema: "messaging",
|
||||
principalTable: "Tickets",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Invoices_PartnerCenters_PartnerCenterId",
|
||||
schema: "payments",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NurseClawbacks_NursePayouts_OriginalPayoutId",
|
||||
schema: "payments",
|
||||
table: "NurseClawbacks");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NurseClawbacks_NursePayouts_RecoveredInPayoutId",
|
||||
schema: "payments",
|
||||
table: "NurseClawbacks");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Refunds_Tickets_TicketId",
|
||||
schema: "payments",
|
||||
table: "Refunds");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Invoices_PartnerCenterId",
|
||||
schema: "payments",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
|
||||
values: new object[] { 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" });
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-9
@@ -1266,15 +1266,6 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
Value = "1"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 19L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
DataType = "bool",
|
||||
Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.",
|
||||
Key = "refund_ticket_required",
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 20L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
@@ -2932,6 +2923,8 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("InvoiceNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PartnerCenterId");
|
||||
|
||||
b.ToTable("Invoices", "payments");
|
||||
});
|
||||
|
||||
@@ -5466,6 +5459,11 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("BookingId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PartnerCenterId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
|
||||
@@ -5650,6 +5648,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OriginalPayoutId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RecoveredInPayoutId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RefundId")
|
||||
@@ -5676,6 +5684,11 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("RequestedByCustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TicketId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b =>
|
||||
|
||||
+10
@@ -55,6 +55,16 @@ internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRep
|
||||
public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
|
||||
public Task<Refund?> GetTrackedRefundByIdAsync(long id, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
|
||||
|
||||
public Task<long?> GetProcessingRefundIdForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(r => r.PaymentTransactionId == paymentTransactionId && r.Status == RefundStatus.Processing)
|
||||
.OrderByDescending(r => r.Id)
|
||||
.Select(r => (long?)r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = TableNoTracking;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Features.Messaging.Commands.PostMessage;
|
||||
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Foundation (handler-level) coverage of the <c>is_internal</c> hard visibility boundary — previously only
|
||||
/// exercised end-to-end (refinement-phase-6 §6.5). The user thread view strips internal notes in the projection;
|
||||
/// the admin view returns them; a non-staff caller can neither post nor read one.
|
||||
/// </summary>
|
||||
public sealed class MessagingInternalBoundaryTests : IDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly UnitOfWork _uow;
|
||||
private readonly int _customerUserId;
|
||||
private readonly int _adminUserId;
|
||||
private readonly long _ticketId;
|
||||
|
||||
public MessagingInternalBoundaryTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
_db.Database.EnsureCreated();
|
||||
_uow = new UnitOfWork(_db);
|
||||
|
||||
var customer = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
|
||||
var admin = new User { UserName = "admin1", PhoneNumber = "09120000009", Gender = "male", Name = "مدیر", FamilyName = "سیستم", IsActive = true };
|
||||
_db.Users.AddRange(customer, admin);
|
||||
_db.SaveChanges();
|
||||
_customerUserId = customer.Id;
|
||||
_adminUserId = admin.Id;
|
||||
|
||||
var ticket = new Ticket { ReferenceCode = "TKT-BND001", Category = TicketCategory.Support, OpenedById = customer.Id };
|
||||
_db.Set<Ticket>().Add(ticket);
|
||||
_db.SaveChanges();
|
||||
_ticketId = ticket.Id;
|
||||
|
||||
_db.Set<TicketParticipant>().Add(new TicketParticipant { TicketId = ticket.Id, UserId = customer.Id, AddedById = customer.Id });
|
||||
_db.Set<TicketMessage>().AddRange(
|
||||
new TicketMessage { TicketId = ticket.Id, SenderId = customer.Id, Body = "public question", IsInternal = false, SentAt = Now },
|
||||
new TicketMessage { TicketId = ticket.Id, SenderId = admin.Id, Body = "internal staff note", IsInternal = true, SentAt = Now.AddMinutes(1) });
|
||||
_db.SaveChanges();
|
||||
}
|
||||
|
||||
private ICurrentUser AsCustomer() => User(_customerUserId, RoleNames.Customer);
|
||||
private ICurrentUser AsAdmin() => User(_adminUserId, RoleNames.Admin);
|
||||
|
||||
private static ICurrentUser User(int id, string role)
|
||||
{
|
||||
var u = Substitute.For<ICurrentUser>();
|
||||
u.UserId.Returns(id);
|
||||
u.Roles.Returns(new[] { role });
|
||||
return u;
|
||||
}
|
||||
|
||||
private IDateTimeProvider Clock()
|
||||
{
|
||||
var c = Substitute.For<IDateTimeProvider>();
|
||||
c.UtcNow.Returns(Now);
|
||||
return c;
|
||||
}
|
||||
|
||||
private GetTicketThreadQueryHandler ThreadHandler(ICurrentUser user) => new(user, _uow, Clock());
|
||||
private PostMessageCommandHandler PostHandler(ICurrentUser user) => new(user, _uow, Clock(), Substitute.For<INotificationDispatcher>());
|
||||
|
||||
[Fact]
|
||||
public async Task User_view_strips_internal_messages()
|
||||
{
|
||||
var result = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: false), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(result.Result.Messages);
|
||||
Assert.All(result.Result.Messages, m => Assert.False(m.IsInternal));
|
||||
Assert.DoesNotContain(result.Result.Messages, m => m.Body == "internal staff note");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_view_includes_internal_messages()
|
||||
{
|
||||
var result = await ThreadHandler(AsAdmin()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: true), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Result.Messages.Count);
|
||||
Assert.Contains(result.Result.Messages, m => m is { IsInternal: true, Body: "internal staff note" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_staff_admin_view_request_is_forbidden()
|
||||
{
|
||||
var result = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: true), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsForbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_staff_cannot_post_an_internal_note()
|
||||
{
|
||||
var result = await PostHandler(AsCustomer()).Handle(new PostMessageCommand(_ticketId, "sneaky", IsInternal: true), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsForbidden);
|
||||
Assert.DoesNotContain(_db.Set<TicketMessage>().AsNoTracking(), m => m.Body == "sneaky");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Staff_internal_note_is_never_returned_in_the_user_view()
|
||||
{
|
||||
var post = await PostHandler(AsAdmin()).Handle(new PostMessageCommand(_ticketId, "second internal note", IsInternal: true), CancellationToken.None);
|
||||
Assert.True(post.IsSuccess);
|
||||
|
||||
var userView = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: false), CancellationToken.None);
|
||||
Assert.True(userView.IsSuccess);
|
||||
Assert.All(userView.Result.Messages, m => Assert.False(m.IsInternal));
|
||||
Assert.DoesNotContain(userView.Result.Messages, m => m.Body == "second internal note");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,45 @@ public class PaymentWebhookTests
|
||||
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Racing_same_key_insert_is_caught_as_an_idempotent_no_op()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var (_, reference) = await SeedPendingAsync(host);
|
||||
|
||||
// A provider that omits external_event_id skips the read-dedup, so the (provider_code, external_event_id)
|
||||
// UNIQUE is the SOLE backstop — exactly the state a true concurrent insert reaches when both requests read
|
||||
// "no existing event" before either commits. Pre-seed the colliding empty-key row so the handler's own
|
||||
// insert loses the unique race and must be treated as an idempotent no-op (DbUpdateException → duplicate).
|
||||
var existing = new PaymentWebhookEvent
|
||||
{
|
||||
ProviderCode = "zarinpal", ExternalEventId = string.Empty, EventType = "payment.succeeded",
|
||||
SignatureValid = true, PayloadJson = "{}", ReceivedAt = Now.UtcDateTime
|
||||
};
|
||||
existing.MarkProcessed(null, Now.UtcDateTime);
|
||||
host.Db.Set<PaymentWebhookEvent>().Add(existing);
|
||||
host.Db.SaveChanges();
|
||||
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
|
||||
var sender = SenderRoutingConfirmTo(confirm);
|
||||
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
||||
|
||||
// No external_event_id in the body → verification.ExternalEventId == "" (the read-dedup is skipped).
|
||||
var body = $"{{\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
|
||||
var result = await handler.Handle(
|
||||
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.Duplicate);
|
||||
Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
|
||||
|
||||
// The insert lost the race → no confirm, no ledger, and only the pre-existing event row survives.
|
||||
await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
|
||||
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
|
||||
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unverified_signature_callback_mutates_nothing()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
using Baya.Application.Features.Refunds.Commands.WriteOffClawback;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// The admin bad-debt path (previously untested — refinement-phase-6 §6.5): writing off a <c>pending</c> nurse
|
||||
/// clawback posts a balanced <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> group and is guarded
|
||||
/// (404 unknown / 409 non-pending / idempotent-ish once resolved).
|
||||
/// </summary>
|
||||
public class ClawbackWriteOffTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static async Task<(long ClawbackId, long BookingId)> SeedPendingClawbackAsync(RefundsTestHost host)
|
||||
{
|
||||
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
|
||||
var create = new CreateRefundCommandHandler(
|
||||
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
|
||||
host.Card(), host.Bnpl(), host.PayoutStatus(paid: true),
|
||||
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
|
||||
|
||||
var refund = await create.Handle(
|
||||
new CreateRefundCommand(bookingId, null, RefundPercentage: 1m, null, null, "customer_request", null, null, null),
|
||||
CancellationToken.None);
|
||||
Assert.True(refund.IsSuccess);
|
||||
Assert.NotNull(refund.Result.ClawbackId);
|
||||
return (refund.Result.ClawbackId!.Value, bookingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_off_posts_a_balanced_bad_debt_group_and_resolves_the_clawback()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var (clawbackId, bookingId) = await SeedPendingClawbackAsync(host);
|
||||
|
||||
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
|
||||
var result = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "uncollectable_after_dispute"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
var clawback = host.Db.Set<NurseClawback>().AsNoTracking().Single(c => c.Id == clawbackId);
|
||||
Assert.Equal(ClawbackStatus.WrittenOff, clawback.Status);
|
||||
|
||||
// The correction group: DEBIT bad_debt == CREDIT nurse_clawback_receivable, both for the clawback amount.
|
||||
var group = host.LedgerFor(bookingId).Where(l => l.SourceRefType == LedgerSourceRefType.Clawback).ToList();
|
||||
Assert.Equal(2, group.Count);
|
||||
Assert.Equal(8_500_000, group.Single(l => l.AccountType == LedgerAccountType.BadDebt && l.Direction == LedgerDirection.Debit).AmountIrr);
|
||||
Assert.Equal(8_500_000, group.Single(l => l.AccountType == LedgerAccountType.NurseClawbackReceivable && l.Direction == LedgerDirection.Credit).AmountIrr);
|
||||
Assert.Equal(group.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
|
||||
group.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_off_of_an_unknown_clawback_is_not_found()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
|
||||
|
||||
var result = await handler.Handle(new WriteOffClawbackCommand(999_999, "x"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_write_off_of_the_same_clawback_is_a_conflict()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var (clawbackId, _) = await SeedPendingClawbackAsync(host);
|
||||
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
|
||||
|
||||
var first = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "uncollectable"), CancellationToken.None);
|
||||
var second = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "again"), CancellationToken.None);
|
||||
|
||||
Assert.True(first.IsSuccess);
|
||||
Assert.False(second.IsSuccess);
|
||||
Assert.True(second.IsConflict);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class RefundHandlerTests
|
||||
=> new(
|
||||
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
|
||||
host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid),
|
||||
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), TestSenders.WithTicketHooks());
|
||||
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
|
||||
|
||||
private static CreateRefundCommand FullRefund(long bookingId)
|
||||
=> new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
|
||||
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// The refinement-phase-6 fix: a BNPL/manual refund that lands in <c>processing</c> can be settled (admin
|
||||
/// confirm / provider cash-back callback) so the deferred <c>refund_payable ↔ escrow_held</c> clearing posts and
|
||||
/// the ledger reconciles — previously that path was unreachable. Also covers <c>mark_failed</c> and replay
|
||||
/// idempotency.
|
||||
/// </summary>
|
||||
public class RefundSettlementTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset Later = new(2026, 8, 12, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static CreateRefundCommandHandler CreateHandler(RefundsTestHost host)
|
||||
=> new(
|
||||
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
|
||||
host.Card(), host.Bnpl(), host.PayoutStatus(paid: false),
|
||||
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
|
||||
|
||||
private static ConfirmRefundSettlementCommandHandler ConfirmHandler(RefundsTestHost host, INotificationDispatcher notifications)
|
||||
=> new(host.UnitOfWork, host.Lock(), host.Clock(Later), notifications);
|
||||
|
||||
private static async Task<long> SeedProcessingBnplRefundAsync(RefundsTestHost host)
|
||||
{
|
||||
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Bnpl);
|
||||
var created = await CreateHandler(host).Handle(
|
||||
new CreateRefundCommand(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(created.IsSuccess);
|
||||
Assert.Equal(RefundStatus.Processing, created.Result.Status); // BNPL waits — no clearing yet
|
||||
Assert.Equal(3, host.LedgerFor(bookingId).Count); // reversal only
|
||||
return created.Result.RefundId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_settlement_posts_the_deferred_clearing_and_the_ledger_reconciles()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var refundId = await SeedProcessingBnplRefundAsync(host);
|
||||
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
|
||||
var notifications = Substitute.For<INotificationDispatcher>();
|
||||
|
||||
var result = await ConfirmHandler(host, notifications).Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(RefundStatus.Succeeded, result.Result.Status);
|
||||
Assert.NotNull(result.Result.CompletedAt);
|
||||
|
||||
var legs = host.LedgerFor(bookingId);
|
||||
Assert.Equal(5, legs.Count); // reversal (3) + clearing (2)
|
||||
Assert.Equal(
|
||||
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
|
||||
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
|
||||
// The clearing leg: refund_payable is fully drained (credit at reversal == debit at clearing) and escrow
|
||||
// is credited back the refunded total — i.e. escrow no longer overstates the held funds.
|
||||
Assert.Equal(10_000_000, legs.Where(l => l.AccountType == LedgerAccountType.RefundPayable && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr));
|
||||
Assert.Equal(10_000_000, legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
|
||||
|
||||
await notifications.Received(1).DispatchAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replayed_confirm_settlement_is_an_idempotent_no_op()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var refundId = await SeedProcessingBnplRefundAsync(host);
|
||||
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
|
||||
var handler = ConfirmHandler(host, Substitute.For<INotificationDispatcher>());
|
||||
|
||||
var first = await handler.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
|
||||
var replay = await handler.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
|
||||
|
||||
Assert.True(first.IsSuccess);
|
||||
Assert.True(replay.IsSuccess);
|
||||
Assert.Equal(RefundStatus.Succeeded, replay.Result.Status);
|
||||
// The clearing is posted exactly once — no double credit to escrow.
|
||||
Assert.Equal(5, host.LedgerFor(bookingId).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_settlement_of_a_missing_refund_is_not_found()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var result = await ConfirmHandler(host, Substitute.For<INotificationDispatcher>())
|
||||
.Handle(new ConfirmRefundSettlementCommand(999_999), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mark_failed_transitions_processing_to_failed_without_posting_a_clearing()
|
||||
{
|
||||
using var host = new RefundsTestHost();
|
||||
var refundId = await SeedProcessingBnplRefundAsync(host);
|
||||
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
|
||||
|
||||
var handler = new MarkRefundSettlementFailedCommandHandler(host.UnitOfWork, host.Lock(), host.Clock(Later));
|
||||
var result = await handler.Handle(new MarkRefundSettlementFailedCommand(refundId, "bank_rejected"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(RefundStatus.Failed, result.Result.Status);
|
||||
Assert.Equal(3, host.LedgerFor(bookingId).Count); // no clearing posted
|
||||
|
||||
// A settled confirm can no longer be applied to a failed refund.
|
||||
var confirm = await ConfirmHandler(host, Substitute.For<INotificationDispatcher>())
|
||||
.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
|
||||
Assert.False(confirm.IsSuccess);
|
||||
Assert.True(confirm.IsConflict);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
|
||||
using Baya.Application.Features.Messaging.Commands.OpenTicket;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Mediator;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
@@ -32,6 +38,10 @@ public sealed class RefundsTestHost : IDisposable
|
||||
public long CustomerId { get; }
|
||||
public int CustomerUserId { get; }
|
||||
public long NurseId { get; }
|
||||
|
||||
/// <summary>A real seeded ticket row so the <c>refunds.ticket_id</c> FK (refinement-phase-6) is satisfied when
|
||||
/// the auto-anchor hook returns this id.</summary>
|
||||
public long TicketId { get; }
|
||||
private readonly long _cityId;
|
||||
private readonly long _categoryId;
|
||||
private readonly long _patientId;
|
||||
@@ -89,6 +99,14 @@ public sealed class RefundsTestHost : IDisposable
|
||||
Db.Set<NurseProfile>().Add(nurse);
|
||||
Db.SaveChanges();
|
||||
NurseId = nurse.Id;
|
||||
|
||||
var ticket = new Ticket
|
||||
{
|
||||
ReferenceCode = "TKT-TEST01", Category = TicketCategory.Refund, OpenedById = CustomerUserId
|
||||
};
|
||||
Db.Set<Ticket>().Add(ticket);
|
||||
Db.SaveChanges();
|
||||
TicketId = ticket.Id;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy
|
||||
@@ -166,12 +184,11 @@ public sealed class RefundsTestHost : IDisposable
|
||||
return c;
|
||||
}
|
||||
|
||||
public IPlatformConfig Config(decimal vatRate = 0.10m, bool ticketRequired = false, int bnplEtaDays = 10)
|
||||
public IPlatformConfig Config(decimal vatRate = 0.10m, int bnplEtaDays = 10)
|
||||
{
|
||||
var cfg = Substitute.For<IPlatformConfig>();
|
||||
cfg.GetConfig<decimal>("vat_rate", Arg.Any<CancellationToken>()).Returns(vatRate);
|
||||
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(0.15m);
|
||||
cfg.GetConfig<bool>("refund_ticket_required", Arg.Any<CancellationToken>()).Returns(ticketRequired);
|
||||
cfg.GetConfig<int>("bnpl_refund_eta_business_days", Arg.Any<CancellationToken>()).Returns(bnplEtaDays);
|
||||
return cfg;
|
||||
}
|
||||
@@ -203,6 +220,19 @@ public sealed class RefundsTestHost : IDisposable
|
||||
|
||||
public IDistributedLock Lock() => new NoOpLock();
|
||||
|
||||
/// <summary>An <see cref="ISender"/> whose refund ticket-anchor hook returns the <b>real</b> seeded
|
||||
/// <see cref="TicketId"/>, so the refund row satisfies the <c>refunds.ticket_id</c> FK.</summary>
|
||||
public ISender Senders()
|
||||
{
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<OpenTicketCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(OperationResult<OpenTicketResult>.SuccessResult(
|
||||
new OpenTicketResult(TicketId, "TKT-TEST01", "open", "refund"))));
|
||||
sender.Send(Arg.Any<AutoCreateCoordinationTicketCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(OperationResult<bool>.SuccessResult(true)));
|
||||
return sender;
|
||||
}
|
||||
|
||||
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
|
||||
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user