refinement phase 6
This commit is contained in:
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user