backend phase 11
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Business-day arithmetic for customer-facing ETAs (the BNPL 7–10-day refund window). Fridays are skipped as
|
||||
/// the Iranian bank weekend; Thursday half-days and public holidays are not modelled here (the ETA is an
|
||||
/// estimate surfaced in the UI, not a settlement guarantee — the authoritative confirmation is the provider
|
||||
/// reconciliation callback). A later refinement can route this through <c>IHolidayCalendar</c>.
|
||||
/// </summary>
|
||||
public static class BusinessDays
|
||||
{
|
||||
public static DateOnly Add(DateOnly start, int businessDays)
|
||||
{
|
||||
var date = start;
|
||||
var added = 0;
|
||||
while (added < businessDays)
|
||||
{
|
||||
date = date.AddDays(1);
|
||||
if (date.DayOfWeek != DayOfWeek.Friday)
|
||||
added++;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Invoices;
|
||||
|
||||
/// <summary>
|
||||
/// The سامانه مودیان e-invoicing rail seam (introduced by b11). Handlers depend only on this contract; the
|
||||
/// mock leaves a newly issued invoice at <c>moadian_status = pending</c> with no reference (no external call).
|
||||
/// A config switch can force a deterministic <c>registered</c> result (with a fake 22-digit reference) so the
|
||||
/// reconciliation/registered path is testable. The real مودیان adapter — enrollment, the معاملات/invoice
|
||||
/// submission API, the <c>pending → submitted → registered</c> reconciliation callback — is a drop-in that
|
||||
/// swaps only this registration.
|
||||
/// </summary>
|
||||
public interface IMoadianClient
|
||||
{
|
||||
ValueTask<MoadianSubmissionResult> SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The minimal facts مودیان needs to register a commission invoice. Money is IRR <c>long</c>.</summary>
|
||||
/// <param name="InvoiceNumber">The platform's own sequential number.</param>
|
||||
/// <param name="BookingId">The booking the invoice is for.</param>
|
||||
/// <param name="GrossIrr">The booking gross.</param>
|
||||
/// <param name="PlatformCommissionIrr">The VAT-relevant commission line.</param>
|
||||
/// <param name="VatIrr">The computed VAT on the commission.</param>
|
||||
public sealed record InvoiceSubmission(
|
||||
string InvoiceNumber,
|
||||
long BookingId,
|
||||
long GrossIrr,
|
||||
long PlatformCommissionIrr,
|
||||
long VatIrr);
|
||||
|
||||
/// <summary>The outcome of a مودیان submission.</summary>
|
||||
/// <param name="Status">A <c>moadian_status</c> code — <c>pending</c> from the mock by default.</param>
|
||||
/// <param name="ReferenceNumber">The 22-digit مودیان reference when registered; null otherwise.</param>
|
||||
public sealed record MoadianSubmissionResult(string Status, string? ReferenceNumber);
|
||||
@@ -0,0 +1,29 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). <b>b12 owns the real, full definition of this
|
||||
/// seam</b>; b11 introduces this minimal shape (revert/update) plus a thin local mock so the <c>bnpl_revert</c>
|
||||
/// refund path is exercised before b12 merges. Money <b>always</b> flows <c>customer ↔ provider ↔ Balinyaar</c>
|
||||
/// — never nurse→customer or Balinyaar→customer direct. A <b>full</b> reversal is <see cref="RevertAsync"/>; a
|
||||
/// <b>partial/shortened</b> one is <see cref="UpdateAsync"/> with a strictly-lower amount. Every amount is IRR
|
||||
/// <c>long</c>; the <paramref name="idempotencyKey"/> makes a retried revert a no-op rather than a double refund.
|
||||
/// </summary>
|
||||
public interface IBnplProvider
|
||||
{
|
||||
/// <summary>Full reversal of a BNPL order back through the provider.</summary>
|
||||
ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Partial revert — reduces the order to a strictly-lower <paramref name="newAmountIrr"/>.</summary>
|
||||
ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The outcome of a BNPL revert/update.</summary>
|
||||
/// <param name="Status">Whether the provider accepted the reversal.</param>
|
||||
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund.</param>
|
||||
/// <param name="ProviderCommissionReversedAmount">The provider's own commission it returned — <b>nullable</b>,
|
||||
/// reconciled from the response, never hardcoded (some providers keep their fee on a refund).</param>
|
||||
public sealed record BnplRevertResult(
|
||||
PaymentProviderStatus Status,
|
||||
string? ExternalRevertReference,
|
||||
long? ProviderCommissionReversedAmount);
|
||||
@@ -0,0 +1,18 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Answers the one question a refund forks on: <b>has the nurse already been paid for this booking?</b> If not
|
||||
/// (the common case — b13 gates payout on <c>dispute_window_ends_at</c>), a refund is a clean <c>nurse_payable</c>
|
||||
/// reversal. If so, the money is gone to an irreversible IBAN transfer, so the refund opens a
|
||||
/// <c>nurse_clawbacks</c> receivable instead.
|
||||
/// <para>
|
||||
/// <b>b13 owns the authoritative implementation</b> (a <c>nurse_payout_booking_links</c> lookup). Until then
|
||||
/// this is derived from the booking's dispute-window close (the same gate b13 pays out on) with a config
|
||||
/// override — a DB-backed facade, registered in Persistence, that b13 swaps in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface INursePayoutStatus
|
||||
{
|
||||
ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -18,9 +18,9 @@ public interface IPaymentProvider
|
||||
/// amount + reference against the gateway before a success callback is allowed to confirm.</summary>
|
||||
ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reverses a captured payment (partial or full). Exposed here so b11 refunds can call it; this
|
||||
/// phase builds no refund flow.</summary>
|
||||
ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default);
|
||||
/// <summary>Reverses a captured payment (partial or full). The <paramref name="idempotencyKey"/> makes a
|
||||
/// retried refund a no-op rather than a double reversal (b11 refunds carry the booking+refund key).</summary>
|
||||
ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The outcome verb of a provider verify/refund — mirrors the wire <c>payment</c> status codes.</summary>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Invoices;
|
||||
using Baya.Domain.Entities.Invoices;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The invoices aggregate. One issued invoice per booking (idempotent). The sequential <c>invoice_number</c> is
|
||||
/// drawn from a concurrency-safe counter row via <see cref="ReserveNextInvoiceNumberAsync"/>, whose increment
|
||||
/// commits in the same transaction as the invoice insert. Money is IRR <c>long</c>.
|
||||
/// </summary>
|
||||
public interface IInvoiceRepository
|
||||
{
|
||||
/// <summary>The booking facts the invoice copies + the owning customer's user id (tenancy). Null when the
|
||||
/// booking is absent.</summary>
|
||||
Task<InvoiceBookingAmounts?> GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The existing issued invoice for a booking, if any — the idempotency check (re-issue returns it).</summary>
|
||||
Task<Invoice?> GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The booking's invoice projected for the read, with the owning customer's user id for tenancy.
|
||||
/// Null when none is issued yet.</summary>
|
||||
Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads and increments the tracked counter row and returns the reserved value. The increment is
|
||||
/// <b>not</b> committed here — the caller commits it alongside the invoice insert so numbers stay gap-free
|
||||
/// under a rollback. Call under the invoice-number lock.</summary>
|
||||
Task<long> ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The refunds/clawbacks aggregate. Writes load tracked rows; reads project to DTOs. The ledger legs
|
||||
/// themselves are appended through <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper) — this
|
||||
/// repo owns only the refund/clawback rows and the money facts a refund is validated against. Money is IRR
|
||||
/// <c>long</c>; the <c>Σ refunded ≤ captured</c> invariant is enforced in the handler from
|
||||
/// <see cref="GetRefundedSumForTransactionAsync"/> under the booking refund lock.
|
||||
/// </summary>
|
||||
public interface IRefundRepository
|
||||
{
|
||||
/// <summary>The booking's frozen money split + b9 cancellation snapshot + its captured (succeeded)
|
||||
/// transaction — everything a refund is decomposed and channel-picked from. Null when the booking has no
|
||||
/// captured payment (refund is impossible).</summary>
|
||||
Task<RefundMoneyContext?> GetRefundContextAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The sum of prior non-failed/-rejected refund <c>amount</c> for a transaction — the authoritative
|
||||
/// backstop for <c>Σ refunded ≤ captured</c> (this refund's amount is added on top in the handler).</summary>
|
||||
Task<long> GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddRefundAsync(Refund refund, CancellationToken cancellationToken);
|
||||
Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked pending clawback — for the admin write-off. Null when absent or already resolved.</summary>
|
||||
Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, 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.
|
||||
/// Null when absent.</summary>
|
||||
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ public interface IUnitOfWork
|
||||
public IBookingRepository BookingRepository { get; }
|
||||
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
|
||||
public IPaymentRepository PaymentRepository { get; }
|
||||
public IRefundRepository RefundRepository { get; }
|
||||
public IInvoiceRepository InvoiceRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Invoices;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Invoices;
|
||||
using Baya.Domain.Entities.Invoices;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Commands.IssueInvoice;
|
||||
|
||||
internal sealed class IssueInvoiceCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
IDistributedLock distributedLock,
|
||||
IMoadianClient moadianClient,
|
||||
IObjectStorage objectStorage)
|
||||
: IRequestHandler<IssueInvoiceCommand, OperationResult<InvoiceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<InvoiceDto>> Handle(IssueInvoiceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Idempotent per booking — re-issue returns the existing invoice, never a second number.
|
||||
var existing = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(existing, PdfUrl(existing)));
|
||||
|
||||
var amounts = await unitOfWork.InvoiceRepository.GetBookingAmountsAsync(request.BookingId, cancellationToken);
|
||||
if (amounts is null)
|
||||
return OperationResult<InvoiceDto>.NotFoundResult("Booking not found.");
|
||||
|
||||
// VAT applies to the commission line ONLY — never the nurse payout — at a config-driven rate. A
|
||||
// vat_rate = 0 exemption yields vat_irr = 0. Integer-only, no float path.
|
||||
var vatRate = await platformConfig.GetConfig<decimal>("vat_rate", cancellationToken);
|
||||
var vatIrr = (long)Math.Round(amounts.PlatformCommissionIrr * vatRate, MidpointRounding.AwayFromZero);
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync("invoice:number", cancellationToken);
|
||||
|
||||
// Re-check under the lock in case a concurrent issue won the race.
|
||||
existing = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(existing, PdfUrl(existing)));
|
||||
|
||||
var sequence = await unitOfWork.InvoiceRepository.ReserveNextInvoiceNumberAsync(cancellationToken);
|
||||
var invoiceNumber = $"INV-{sequence:D10}";
|
||||
|
||||
var submission = new InvoiceSubmission(invoiceNumber, request.BookingId, amounts.GrossIrr, amounts.PlatformCommissionIrr, vatIrr);
|
||||
var moadian = await moadianClient.SubmitAsync(submission, cancellationToken);
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
BookingId = request.BookingId,
|
||||
InvoiceNumber = invoiceNumber,
|
||||
IssuingEntityType = InvoiceIssuingEntityType.Platform,
|
||||
GrossIrr = amounts.GrossIrr,
|
||||
PlatformCommissionIrr = amounts.PlatformCommissionIrr,
|
||||
BnplCommissionIrr = amounts.BnplCommissionIrr,
|
||||
VatRate = vatRate,
|
||||
VatIrr = vatIrr,
|
||||
IssuedAt = now
|
||||
};
|
||||
invoice.ApplyMoadianResult(moadian.Status, moadian.ReferenceNumber);
|
||||
|
||||
await unitOfWork.InvoiceRepository.AddInvoiceAsync(invoice, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Commits the counter increment + the invoice row together, so the sequence stays gap-free.
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Lost the UNIQUE(booking_id) race — return the invoice the winner created (idempotent no-op).
|
||||
await unitOfWork.RollBackAsync();
|
||||
var winner = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken);
|
||||
return winner is null
|
||||
? OperationResult<InvoiceDto>.ConflictResult("An invoice already exists for this booking.")
|
||||
: OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(winner, PdfUrl(winner)));
|
||||
}
|
||||
|
||||
return OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(invoice, PdfUrl(invoice)));
|
||||
}
|
||||
|
||||
private string? PdfUrl(Invoice invoice)
|
||||
=> invoice.PdfStorageKey is { } key ? objectStorage.GetUrl(key) : null;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Commands.IssueInvoice;
|
||||
|
||||
public sealed class IssueInvoiceCommandValidator : AbstractValidator<IssueInvoiceCommand>
|
||||
{
|
||||
public IssueInvoiceCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Invoices;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Commands.IssueInvoice;
|
||||
|
||||
/// <summary>Issues the booking's official commission invoice — a sequential number, config-driven VAT on the
|
||||
/// commission line only, and a (mocked) مودیان submission. Idempotent per booking: re-issue returns the
|
||||
/// existing invoice.</summary>
|
||||
public record IssueInvoiceCommand(long BookingId) : IRequest<OperationResult<InvoiceDto>>;
|
||||
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Invoices;
|
||||
using Baya.Domain.Entities.Invoices;
|
||||
|
||||
namespace Baya.Application.Features.Invoices;
|
||||
|
||||
/// <summary>Maps an <see cref="Invoice"/> to its wire DTO, stringifying every IRR amount per the money
|
||||
/// convention. The <paramref name="pdfUrl"/> is resolved by the handler through <c>IObjectStorage</c>.</summary>
|
||||
internal static class InvoiceDtoFactory
|
||||
{
|
||||
public static InvoiceDto FromEntity(Invoice invoice, string? pdfUrl) => new(
|
||||
invoice.Id,
|
||||
invoice.BookingId,
|
||||
invoice.InvoiceNumber,
|
||||
invoice.IssuingEntityType,
|
||||
invoice.GrossIrr.ToString(),
|
||||
invoice.PlatformCommissionIrr.ToString(),
|
||||
invoice.BnplCommissionIrr?.ToString(),
|
||||
invoice.VatRate,
|
||||
invoice.VatIrr.ToString(),
|
||||
invoice.MoadianReferenceNumber,
|
||||
invoice.MoadianStatus,
|
||||
pdfUrl,
|
||||
invoice.IssuedAt);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Invoices;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Queries.GetInvoice;
|
||||
|
||||
internal sealed class GetInvoiceQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
ICurrentUser currentUser,
|
||||
IObjectStorage objectStorage)
|
||||
: IRequestHandler<GetInvoiceQuery, OperationResult<InvoiceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<InvoiceDto>> Handle(GetInvoiceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await unitOfWork.InvoiceRepository.GetByBookingIdAsync(request.BookingId, cancellationToken);
|
||||
if (projection is null)
|
||||
return OperationResult<InvoiceDto>.NotFoundResult("Invoice not found.");
|
||||
|
||||
// Admins read any invoice; a customer reads only their own booking's — a cross-customer read is a
|
||||
// clean not-found, never a leak.
|
||||
var isAdmin = currentUser.Roles.Contains(RoleNames.Admin);
|
||||
if (!isAdmin && projection.CustomerUserId != currentUser.UserId)
|
||||
return OperationResult<InvoiceDto>.NotFoundResult("Invoice not found.");
|
||||
|
||||
var pdfUrl = projection.PdfStorageKey is { } key ? objectStorage.GetUrl(key) : null;
|
||||
return OperationResult<InvoiceDto>.SuccessResult(projection.Invoice with { PdfUrl = pdfUrl });
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Invoices;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Queries.GetInvoice;
|
||||
|
||||
/// <summary>The booking's invoice for the customer (tenancy-scoped) or an admin. Surfaces the number, amounts,
|
||||
/// VAT, مودیان status and a PDF download URL when present.</summary>
|
||||
public record GetInvoiceQuery(long BookingId) : IRequest<OperationResult<InvoiceDto>>;
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
|
||||
/// <summary>
|
||||
/// The money-side of a cancellation/dispute refund. Runs the whole reversal under
|
||||
/// <c>lock(booking:{id}:refund)</c> so a cancellation-driven and a webhook-driven refund can't both fire and
|
||||
/// breach <c>Σ refunded ≤ captured</c>. It decomposes the refund across both fee legs, forks on whether the
|
||||
/// nurse was already paid (clean <c>nurse_payable</c> reversal vs a <c>nurse_clawbacks</c> receivable), executes
|
||||
/// the channel behind its seam, and posts the balanced ledger group(s) via b10's helper. The channel-execution
|
||||
/// and ledger-posting "internal steps" from the phase are cohesive private steps here (mirroring b10's
|
||||
/// <c>ConfirmPaymentAndPostLedger</c>) so they stay atomic under one lock.
|
||||
/// </summary>
|
||||
internal sealed class CreateRefundCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ICurrentUser currentUser,
|
||||
IPaymentProvider paymentProvider,
|
||||
IBnplProvider bnplProvider,
|
||||
INursePayoutStatus nursePayoutStatus,
|
||||
ISupportAlertService supportAlerts,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<CreateRefundCommand, OperationResult<CreateRefundResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CreateRefundResult>> Handle(CreateRefundCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Ticket link is required, but config-gated off until b15 ships the tickets table (the FK is nullable
|
||||
// now for exactly this forward-dep). Read the gate through the typed config accessor, never hardcoded.
|
||||
var ticketRequired = await platformConfig.GetConfig<bool>("refund_ticket_required", cancellationToken);
|
||||
if (ticketRequired && request.TicketId is null)
|
||||
return OperationResult<CreateRefundResult>.FailureResult("ticket_id", "A support ticket is required to issue a refund.");
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync($"booking:{request.BookingId}:refund", cancellationToken);
|
||||
|
||||
var context = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken);
|
||||
if (context is null)
|
||||
return OperationResult<CreateRefundResult>.NotFoundResult("No captured payment exists for this booking to refund.");
|
||||
|
||||
var decomposition = ResolveDecomposition(request, context);
|
||||
if (decomposition is null)
|
||||
return OperationResult<CreateRefundResult>.FailureResult(
|
||||
"refund_percentage", "Provide a refund percentage, explicit legs, or a booking cancellation snapshot.");
|
||||
|
||||
var (platformFeeRefunded, nursePayoutRefunded, resolvedPct) = decomposition.Value;
|
||||
var amount = platformFeeRefunded + nursePayoutRefunded;
|
||||
|
||||
if (amount <= 0)
|
||||
return OperationResult<CreateRefundResult>.FailureResult("amount", "The refund amount must be positive.");
|
||||
if (platformFeeRefunded > context.CommissionIrr || nursePayoutRefunded > context.PayoutIrr)
|
||||
return OperationResult<CreateRefundResult>.FailureResult("amount", "A refund leg exceeds the captured booking leg.");
|
||||
|
||||
// Σ refunded ≤ captured — the authoritative backstop, summed under the lock.
|
||||
var priorRefunded = await unitOfWork.RefundRepository.GetRefundedSumForTransactionAsync(context.PaymentTransactionId, cancellationToken);
|
||||
if (priorRefunded + amount > context.CapturedAmount)
|
||||
return OperationResult<CreateRefundResult>.ConflictResult(
|
||||
"This refund would push total refunds over the captured amount.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
var channel = SelectChannel(request, context.GatewayType);
|
||||
var idempotencyKey = $"booking:{request.BookingId}:refund:{context.PaymentTransactionId}:{priorRefunded + amount}";
|
||||
|
||||
var refund = new Refund
|
||||
{
|
||||
PaymentTransactionId = context.PaymentTransactionId,
|
||||
BookingId = context.BookingId,
|
||||
RequestedByCustomerId = context.CustomerId,
|
||||
TicketId = request.TicketId,
|
||||
Amount = amount,
|
||||
PlatformFeeRefundedIrr = platformFeeRefunded,
|
||||
NursePayoutRefundedIrr = nursePayoutRefunded,
|
||||
RefundPercentage = resolvedPct,
|
||||
RefundChannel = channel,
|
||||
ReasonCategory = request.ReasonCategory,
|
||||
ReasonNotes = request.ReasonNotes,
|
||||
AdminNotes = request.AdminNotes,
|
||||
ApprovedByAdminId = currentUser.UserId,
|
||||
CancellationPolicyCode = context.CancellationPolicyCode,
|
||||
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);
|
||||
|
||||
await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
if (!executed)
|
||||
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.
|
||||
var isNursePaid = await nursePayoutStatus.IsNursePaidForBookingAsync(context.BookingId, cancellationToken);
|
||||
long? clawbackId = null;
|
||||
|
||||
var reversal = isNursePaid
|
||||
? LedgerPosting.ClawbackReversalPostPayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now)
|
||||
: LedgerPosting.RefundReversalPrePayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(reversal, cancellationToken);
|
||||
|
||||
NurseClawback? clawback = null;
|
||||
if (isNursePaid && nursePayoutRefunded > 0)
|
||||
{
|
||||
clawback = new NurseClawback
|
||||
{
|
||||
NurseId = context.NurseId,
|
||||
BookingId = context.BookingId,
|
||||
RefundId = refund.Id,
|
||||
AmountIrr = nursePayoutRefunded
|
||||
};
|
||||
await unitOfWork.RefundRepository.AddClawbackAsync(clawback, cancellationToken);
|
||||
}
|
||||
|
||||
// The customer cash-back clears refund_payable ↔ escrow_held only once confirmed — immediate for a
|
||||
// succeeded card refund; deferred to reconciliation while a BNPL/manual refund sits in processing.
|
||||
if (refund.Status == RefundStatus.Succeeded)
|
||||
{
|
||||
var clearing = LedgerPosting.RefundPayableClearing(context.BookingId, amount, refund.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken);
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Self-committing facades run only after the atomic commit (they flush the shared DbContext).
|
||||
if (clawback is not null)
|
||||
{
|
||||
clawbackId = clawback.Id;
|
||||
await supportAlerts.RaiseAsync(
|
||||
SupportAlertType.NurseClawback, entityType: "refund", entityId: refund.Id.ToString(),
|
||||
severity: SupportAlertSeverity.High, bookingId: context.BookingId, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await NotifyCustomerAsync(context, refund, cancellationToken);
|
||||
|
||||
return OperationResult<CreateRefundResult>.SuccessResult(new CreateRefundResult(
|
||||
refund.Id, context.BookingId, refund.Status, channel,
|
||||
amount.ToString(), platformFeeRefunded.ToString(), nursePayoutRefunded.ToString(),
|
||||
refund.ExpectedCustomerRefundEta, clawbackId));
|
||||
}
|
||||
|
||||
private static (long PlatformFee, long NursePayout, decimal Pct)? ResolveDecomposition(CreateRefundCommand request, RefundMoneyContext context)
|
||||
{
|
||||
// Admin-supplied explicit legs take precedence; they must sum to the amount (validator ensures both set).
|
||||
if (request.PlatformFeeRefundedIrr is { } fee && request.NursePayoutRefundedIrr is { } payout)
|
||||
{
|
||||
var amount = fee + payout;
|
||||
var impliedPct = context.GrossPriceIrr > 0 ? (decimal)amount / context.GrossPriceIrr : 0m;
|
||||
return (fee, payout, impliedPct);
|
||||
}
|
||||
|
||||
// Otherwise pro-rata each booking leg at the resolved fraction: the command's, else the b9 snapshot's
|
||||
// (percentage stored 0–100 on the booking) — never re-resolved from live config.
|
||||
var pct = request.RefundPercentage
|
||||
?? (context.CancellationRefundPercentage is { } snap ? snap / 100m : (decimal?)null);
|
||||
if (pct is not { } fraction || fraction <= 0)
|
||||
return null;
|
||||
|
||||
var feeLeg = (long)Math.Round(context.CommissionIrr * fraction, MidpointRounding.AwayFromZero);
|
||||
var payoutLeg = (long)Math.Round(context.PayoutIrr * fraction, MidpointRounding.AwayFromZero);
|
||||
return (feeLeg, payoutLeg, fraction);
|
||||
}
|
||||
|
||||
private static string SelectChannel(CreateRefundCommand request, string gatewayType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.ManualBankReference))
|
||||
return RefundChannel.Manual;
|
||||
return gatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard;
|
||||
}
|
||||
|
||||
private async Task<bool> ExecuteChannelAsync(
|
||||
Refund refund, string channel, RefundMoneyContext context, string? manualBankReference,
|
||||
long amount, long priorRefunded, string idempotencyKey, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (channel)
|
||||
{
|
||||
case RefundChannel.PspCard:
|
||||
{
|
||||
var result = await paymentProvider.RefundAsync(
|
||||
context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken);
|
||||
if (result.Status != PaymentProviderStatus.Succeeded)
|
||||
{
|
||||
refund.MarkFailed(result.Status.ToString());
|
||||
return false;
|
||||
}
|
||||
refund.MarkSucceededCard(result.GatewayRefundReference ?? idempotencyKey, now);
|
||||
return true;
|
||||
}
|
||||
case RefundChannel.BnplRevert:
|
||||
{
|
||||
// Full = revert; partial/shortened = update to the strictly-lower remaining amount.
|
||||
var isFull = priorRefunded == 0 && amount == context.CapturedAmount;
|
||||
var result = isFull
|
||||
? await bnplProvider.RevertAsync(context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken)
|
||||
: await bnplProvider.UpdateAsync(context.GatewayReferenceCode ?? string.Empty, context.CapturedAmount - (priorRefunded + amount), idempotencyKey, cancellationToken);
|
||||
if (result.Status == PaymentProviderStatus.Failed)
|
||||
{
|
||||
refund.MarkFailed(result.Status.ToString());
|
||||
return false;
|
||||
}
|
||||
var eta = await ExpectedEtaAsync(now, cancellationToken);
|
||||
refund.MarkProcessing(result.ExternalRevertReference, eta);
|
||||
return true;
|
||||
}
|
||||
default: // manual out-of-band bank refund — admin records the bank ref; the cash-back confirms later.
|
||||
{
|
||||
refund.MarkProcessing(manualBankReference, null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DateOnly> ExpectedEtaAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var days = await platformConfig.GetConfig<int>("bnpl_refund_eta_business_days", cancellationToken);
|
||||
return BusinessDays.Add(DateOnly.FromDateTime(now), days);
|
||||
}
|
||||
|
||||
private async Task NotifyCustomerAsync(RefundMoneyContext context, Refund refund, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { booking_id = context.BookingId, refund_id = refund.Id, refund.Status });
|
||||
var body = refund.Status == RefundStatus.Succeeded
|
||||
? "Your refund has been processed."
|
||||
: "Your refund is on its way and should arrive within a few business days.";
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(context.CustomerUserId, "refund_issued", "Refund issued", body, payload),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
|
||||
public sealed class CreateRefundCommandValidator : AbstractValidator<CreateRefundCommand>
|
||||
{
|
||||
public CreateRefundCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingId).GreaterThan(0);
|
||||
|
||||
When(x => x.RefundPercentage.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.RefundPercentage!.Value)
|
||||
.GreaterThan(0m).LessThanOrEqualTo(1m)
|
||||
.WithMessage("refund_percentage must be a fraction in (0, 1].");
|
||||
});
|
||||
|
||||
// Explicit legs are all-or-nothing and non-negative; the handler enforces amount = fee + payout and the
|
||||
// Σ refunded ≤ captured invariant under the booking refund lock.
|
||||
When(x => x.PlatformFeeRefundedIrr.HasValue || x.NursePayoutRefundedIrr.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.PlatformFeeRefundedIrr)
|
||||
.NotNull().GreaterThanOrEqualTo(0)
|
||||
.WithMessage("Both refund legs must be supplied together.");
|
||||
RuleFor(x => x.NursePayoutRefundedIrr)
|
||||
.NotNull().GreaterThanOrEqualTo(0)
|
||||
.WithMessage("Both refund legs must be supplied together.");
|
||||
});
|
||||
|
||||
RuleFor(x => x.ReasonCategory).MaximumLength(50);
|
||||
RuleFor(x => x.ReasonNotes).MaximumLength(1000);
|
||||
RuleFor(x => x.AdminNotes).MaximumLength(1000);
|
||||
RuleFor(x => x.ManualBankReference).MaximumLength(200);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-initiated, ticket-linked reversal of a captured booking payment. Either supply <see cref="RefundPercentage"/>
|
||||
/// (a 0–1 fraction applied pro-rata to the booking's commission/payout legs) or the explicit
|
||||
/// <see cref="PlatformFeeRefundedIrr"/> + <see cref="NursePayoutRefundedIrr"/> legs (which must still sum to the
|
||||
/// refunded amount). When neither the command nor the booking's cancellation snapshot resolves a percentage the
|
||||
/// request is rejected. Providing <see cref="ManualBankReference"/> forces the <c>manual</c> (out-of-band bank)
|
||||
/// channel; otherwise the channel is derived from the original payment type (card ⇒ <c>psp_card</c>, BNPL ⇒
|
||||
/// <c>bnpl_revert</c>).
|
||||
/// </summary>
|
||||
public record CreateRefundCommand(
|
||||
long BookingId,
|
||||
long? TicketId,
|
||||
decimal? RefundPercentage,
|
||||
long? PlatformFeeRefundedIrr,
|
||||
long? NursePayoutRefundedIrr,
|
||||
string? ReasonCategory,
|
||||
string? ReasonNotes,
|
||||
string? AdminNotes,
|
||||
string? ManualBankReference) : IRequest<OperationResult<CreateRefundResult>>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback;
|
||||
|
||||
internal sealed class WriteOffClawbackCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<WriteOffClawbackCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(WriteOffClawbackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var clawback = await unitOfWork.RefundRepository.GetTrackedClawbackByIdAsync(request.ClawbackId, cancellationToken);
|
||||
if (clawback is null)
|
||||
return OperationResult<bool>.NotFoundResult("Clawback not found.");
|
||||
if (!clawback.IsPending)
|
||||
return OperationResult<bool>.ConflictResult("Only a pending clawback can be written off.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
clawback.WriteOff(request.Reason, now);
|
||||
|
||||
var correction = LedgerPosting.ClawbackWriteOff(
|
||||
clawback.BookingId, clawback.NurseId, clawback.AmountIrr, clawback.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(correction, cancellationToken);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback;
|
||||
|
||||
public sealed class WriteOffClawbackCommandValidator : AbstractValidator<WriteOffClawbackCommand>
|
||||
{
|
||||
public WriteOffClawbackCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ClawbackId).GreaterThan(0);
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback;
|
||||
|
||||
/// <summary>Admin marks a <c>pending</c> nurse clawback uncollectable, posting the balancing
|
||||
/// <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> correction. Recovery via payout netting is b13.</summary>
|
||||
public record WriteOffClawbackCommand(long ClawbackId, string Reason) : IRequest<OperationResult<bool>>;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.GetRefundStatus;
|
||||
|
||||
internal sealed class GetRefundStatusQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<GetRefundStatusQuery, OperationResult<RefundStatusDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RefundStatusDto>> Handle(GetRefundStatusQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
|
||||
|
||||
// A cross-customer access is indistinguishable from "not found" — never confirm the refund exists.
|
||||
if (projection is null || projection.CustomerUserId != currentUser.UserId)
|
||||
return OperationResult<RefundStatusDto>.NotFoundResult("Refund not found.");
|
||||
|
||||
return OperationResult<RefundStatusDto>.SuccessResult(projection.Refund);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.GetRefundStatus;
|
||||
|
||||
/// <summary>The customer-facing status of their own refund — status, channel, amount and the BNPL ETA window.
|
||||
/// Tenancy-scoped to the booking's customer via <c>ICurrentUser</c>; another customer's refund is not visible.</summary>
|
||||
public record GetRefundStatusQuery(long RefundId) : IRequest<OperationResult<RefundStatusDto>>;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.ListRefunds;
|
||||
|
||||
internal sealed class ListRefundsQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListRefundsQuery, OperationResult<PagedResult<RefundListItemDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<RefundListItemDto>>> Handle(ListRefundsQuery 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.RefundRepository.ListAsync(request.BookingId, request.Status, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<RefundListItemDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.ListRefunds;
|
||||
|
||||
/// <summary>Admin refund worklist — projected + paginated; surfaces channel, decomposed legs, status, the BNPL
|
||||
/// ETA and the policy snapshot. Optional <paramref name="BookingId"/> / <paramref name="Status"/> filters.</summary>
|
||||
public record ListRefundsQuery(long? BookingId = null, string? Status = null, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<RefundListItemDto>>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Invoices;
|
||||
|
||||
/// <summary>The booking facts <c>IssueInvoiceCommand</c> copies onto the invoice, plus the owning customer's
|
||||
/// user id for the tenancy-scoped read. Money is IRR <c>long</c>.</summary>
|
||||
public record InvoiceBookingAmounts(
|
||||
long BookingId,
|
||||
int CustomerUserId,
|
||||
long GrossIrr,
|
||||
long PlatformCommissionIrr,
|
||||
long? BnplCommissionIrr);
|
||||
|
||||
/// <summary>The booking's official invoice. Money crosses the wire as digit strings; the PDF URL is derived
|
||||
/// from the stored object key when present.</summary>
|
||||
public record InvoiceDto(
|
||||
long Id,
|
||||
long BookingId,
|
||||
string InvoiceNumber,
|
||||
string IssuingEntityType,
|
||||
string GrossIrr,
|
||||
string PlatformCommissionIrr,
|
||||
string? BnplCommissionIrr,
|
||||
decimal VatRate,
|
||||
string VatIrr,
|
||||
string? MoadianReferenceNumber,
|
||||
string? MoadianStatus,
|
||||
string? PdfUrl,
|
||||
DateTime IssuedAt);
|
||||
|
||||
/// <summary>The tenancy envelope for the customer invoice read: the owning customer's user id plus the DTO
|
||||
/// (with the raw PDF key so the handler can turn it into a URL through <c>IObjectStorage</c>).</summary>
|
||||
public record InvoiceProjection(int CustomerUserId, string? PdfStorageKey, InvoiceDto Invoice);
|
||||
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// Everything <c>CreateRefundCommand</c> needs in one read to decompose, pick the channel and enforce
|
||||
/// <c>Σ refunded ≤ captured</c>: the booking's frozen three-amount split, the b9 cancellation snapshot (never
|
||||
/// re-resolved live), and the captured transaction it reverses. Money is IRR <c>long</c>.
|
||||
/// </summary>
|
||||
public record RefundMoneyContext(
|
||||
long BookingId,
|
||||
long CustomerId,
|
||||
int CustomerUserId,
|
||||
long NurseId,
|
||||
long GrossPriceIrr,
|
||||
long CommissionIrr,
|
||||
long PayoutIrr,
|
||||
string? CancellationPolicyCode,
|
||||
decimal? CancellationRefundPercentage,
|
||||
long? RefundableAmountIrr,
|
||||
long PaymentTransactionId,
|
||||
string? GatewayReferenceCode,
|
||||
long CapturedAmount,
|
||||
string GatewayType);
|
||||
|
||||
/// <summary>The admin refund worklist row — channel, decomposed legs, status, policy snapshot and the BNPL ETA.
|
||||
/// Money crosses the wire as digit strings.</summary>
|
||||
public record RefundListItemDto(
|
||||
long Id,
|
||||
long BookingId,
|
||||
long PaymentTransactionId,
|
||||
string Amount,
|
||||
string PlatformFeeRefundedIrr,
|
||||
string NursePayoutRefundedIrr,
|
||||
string RefundChannel,
|
||||
string Status,
|
||||
decimal RefundPercentage,
|
||||
string? ReasonCategory,
|
||||
string? CancellationPolicyCode,
|
||||
decimal? RefundPercentageApplied,
|
||||
DateOnly? ExpectedCustomerRefundEta,
|
||||
string? GatewayRefundReference,
|
||||
string? ExternalRevertReference,
|
||||
DateTime? ProcessedAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>The customer-facing status of their own refund — the reference is masked, money is a digit string,
|
||||
/// and the BNPL 7–10-business-day window is surfaced so the UI can show "on its way, ~N days".</summary>
|
||||
public record RefundStatusDto(
|
||||
long Id,
|
||||
long BookingId,
|
||||
string Status,
|
||||
string RefundChannel,
|
||||
string Amount,
|
||||
DateOnly? ExpectedCustomerRefundEta,
|
||||
string? Reference);
|
||||
|
||||
/// <summary>The tenancy envelope for the customer refund-status read: the owning customer's user id (compared
|
||||
/// 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 <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(
|
||||
long RefundId,
|
||||
long BookingId,
|
||||
string Status,
|
||||
string RefundChannel,
|
||||
string Amount,
|
||||
string PlatformFeeRefundedIrr,
|
||||
string NursePayoutRefundedIrr,
|
||||
DateOnly? ExpectedCustomerRefundEta,
|
||||
long? ClawbackId);
|
||||
@@ -0,0 +1,62 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Invoices;
|
||||
|
||||
/// <summary>
|
||||
/// The minimal official receipt per booking. <b>VAT applies to the platform commission line only</b>
|
||||
/// (<see cref="PlatformCommissionIrr"/>) — never the nurse's earnings — following the Snapp/Tapsi precedent
|
||||
/// that the nurse is the taxable seller of the care service and the platform's commission is its own taxable
|
||||
/// revenue. <see cref="VatIrr"/> is computed integer-only from a <b>config-driven</b> <see cref="VatRate"/>
|
||||
/// (default 0.10); a <c>vat_rate = 0</c> exemption yields <see cref="VatIrr"/> = 0.
|
||||
/// <para>
|
||||
/// <see cref="InvoiceNumber"/> is UNIQUE and <b>sequential</b>, drawn from a concurrency-safe counter row
|
||||
/// (never random/timestamp-derived). One issued invoice per booking (idempotent).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Invoice : BaseEntity<long>
|
||||
{
|
||||
public long BookingId { get; set; }
|
||||
|
||||
/// <summary>Official sequential number (UNIQUE) — drawn from <see cref="InvoiceNumberSequence"/>.</summary>
|
||||
public string InvoiceNumber { get; set; } = null!;
|
||||
|
||||
/// <summary>An <see cref="InvoiceIssuingEntityType"/> code.</summary>
|
||||
public string IssuingEntityType { get; set; } = InvoiceIssuingEntityType.Platform;
|
||||
|
||||
/// <summary>The licensed center that is merchant-of-record, when the issuer is a partner center (b15).</summary>
|
||||
public long? PartnerCenterId { get; set; }
|
||||
|
||||
public long GrossIrr { get; set; }
|
||||
|
||||
/// <summary>The VAT-relevant line — VAT is computed on this, never on the nurse payout.</summary>
|
||||
public long PlatformCommissionIrr { get; set; }
|
||||
|
||||
public long? BnplCommissionIrr { get; set; }
|
||||
|
||||
/// <summary>Config-driven VAT rate snapshot (default 0.10) frozen at issue time.</summary>
|
||||
public decimal VatRate { get; set; }
|
||||
|
||||
/// <summary>round(<see cref="PlatformCommissionIrr"/> × <see cref="VatRate"/>) — integer-only, no float path.</summary>
|
||||
public long VatIrr { get; set; }
|
||||
|
||||
/// <summary>The سامانه مودیان 22-digit reference when registered; null until then.</summary>
|
||||
public string? MoadianReferenceNumber { get; set; }
|
||||
|
||||
/// <summary>A <see cref="MoadianStatus"/> code.</summary>
|
||||
public string? MoadianStatus { get; set; }
|
||||
|
||||
/// <summary>An <c>IObjectStorage</c> key for the optional invoice PDF.</summary>
|
||||
public string? PdfStorageKey { get; set; }
|
||||
|
||||
public DateTime IssuedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>Records the outcome of a مودیان submission (mock: pending/no-ref; forced: registered/ref).</summary>
|
||||
public void ApplyMoadianResult(string status, string? referenceNumber)
|
||||
{
|
||||
MoadianStatus = status;
|
||||
MoadianReferenceNumber = referenceNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Baya.Domain.Entities.Invoices;
|
||||
|
||||
/// <summary>The closed <c>invoices.issuing_entity_type</c> code set — who issues the receipt. A partner center
|
||||
/// (b15) is the merchant-of-record for its employees' bookings; otherwise the platform issues it.</summary>
|
||||
public static class InvoiceIssuingEntityType
|
||||
{
|
||||
public const string Platform = "platform";
|
||||
public const string PartnerCenter = "partner_center";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Invoices;
|
||||
|
||||
/// <summary>
|
||||
/// The gap-free, concurrency-safe counter behind <c>invoices.invoice_number</c>. A single seeded row holds the
|
||||
/// next value; issuing an invoice takes the money-path lock, reads and increments <see cref="NextValue"/>, and
|
||||
/// commits the increment <b>in the same transaction</b> as the invoice insert — so a rollback rolls both back
|
||||
/// and numbers stay sequential and unique. A dedicated counter row (not <c>MAX()+1</c>) keeps this correct under
|
||||
/// concurrency and portable across SQL Server and the SQLite test provider (no provider-specific sequence).
|
||||
/// </summary>
|
||||
public class InvoiceNumberSequence : IEntity
|
||||
{
|
||||
/// <summary>Fixed singleton key — there is exactly one counter row (id 1).</summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>The next number to hand out. Incremented under the invoice lock.</summary>
|
||||
public long NextValue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Baya.Domain.Entities.Invoices;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>invoices.moadian_status</c> code set — the سامانه مودیان e-invoicing submission state. The mock
|
||||
/// <c>IMoadianClient</c> leaves a newly issued invoice at <see cref="Pending"/> with no reference; a config
|
||||
/// switch can force <see cref="Registered"/> (with a fake 22-digit ref) so the reconciliation path is testable.
|
||||
/// The real adapter walks <see cref="Pending"/> → <see cref="Submitted"/> → <see cref="Registered"/>.
|
||||
/// </summary>
|
||||
public static class MoadianStatus
|
||||
{
|
||||
/// <summary>Issued locally; not yet sent to مودیان.</summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>Sent to مودیان; awaiting the registered reference.</summary>
|
||||
public const string Submitted = "submitted";
|
||||
|
||||
/// <summary>مودیان returned the 22-digit reference — the invoice is officially registered.</summary>
|
||||
public const string Registered = "registered";
|
||||
|
||||
/// <summary>مودیان rejected the submission.</summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -28,25 +28,119 @@ public static class LedgerPosting
|
||||
$"Card-capture group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
|
||||
|
||||
var group = Guid.NewGuid();
|
||||
|
||||
LedgerEntry Leg(string account, string direction, long amount, long? nurse) => new()
|
||||
{
|
||||
TransactionGroupId = group,
|
||||
AccountType = account,
|
||||
Direction = direction,
|
||||
AmountIrr = amount,
|
||||
NurseId = nurse,
|
||||
BookingId = bookingId,
|
||||
SourceRefType = LedgerSourceRefType.PaymentTransaction,
|
||||
SourceRefId = paymentTransactionId,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
|
||||
return
|
||||
[
|
||||
Leg(LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null),
|
||||
Leg(LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null),
|
||||
Leg(LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId)
|
||||
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt),
|
||||
Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt),
|
||||
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt)
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>pre-payout refund reversal</b> (the nurse has not been paid, the common case): <c>DEBIT
|
||||
/// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum)</c> under one group.
|
||||
/// Simply un-accrues what capture posted. The customer cash-back is cleared separately by
|
||||
/// <see cref="RefundPayableClearing"/> once the provider confirms it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> RefundReversalPrePayout(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long platformFeeRefundedIrr,
|
||||
long nursePayoutRefundedIrr,
|
||||
long refundId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
var group = Guid.NewGuid();
|
||||
var total = platformFeeRefundedIrr + nursePayoutRefundedIrr;
|
||||
var legs = new List<LedgerEntry>();
|
||||
|
||||
if (platformFeeRefundedIrr > 0)
|
||||
legs.Add(Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit, platformFeeRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
|
||||
if (nursePayoutRefundedIrr > 0)
|
||||
legs.Add(Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, nursePayoutRefundedIrr, nurseId, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
|
||||
|
||||
legs.Add(Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Credit, total, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
|
||||
return legs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>post-payout clawback reversal</b> (the nurse was already paid — an irreversible IBAN transfer):
|
||||
/// identical to the pre-payout group except the payout leg debits <c>nurse_clawback_receivable</c> (money
|
||||
/// owed back by the nurse) instead of un-accruing <c>nurse_payable</c>: <c>DEBIT platform_revenue fee +
|
||||
/// DEBIT nurse_clawback_receivable payout / CREDIT refund_payable (sum)</c>. A <c>nurse_clawbacks</c> row
|
||||
/// tracks the workflow; b13 nets the receivable out of a later payout.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> ClawbackReversalPostPayout(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long platformFeeRefundedIrr,
|
||||
long nursePayoutRefundedIrr,
|
||||
long refundId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
var group = Guid.NewGuid();
|
||||
var total = platformFeeRefundedIrr + nursePayoutRefundedIrr;
|
||||
var legs = new List<LedgerEntry>();
|
||||
|
||||
if (platformFeeRefundedIrr > 0)
|
||||
legs.Add(Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit, platformFeeRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
|
||||
if (nursePayoutRefundedIrr > 0)
|
||||
legs.Add(Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Debit, nursePayoutRefundedIrr, nurseId, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
|
||||
|
||||
legs.Add(Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Credit, total, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
|
||||
return legs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the customer cash-back once the provider confirms it (card immediately; BNPL/manual on
|
||||
/// reconciliation): <c>DEBIT refund_payable / CREDIT escrow_held</c> for the refunded total. Path- and
|
||||
/// channel-independent — the same second leg follows either the pre-payout or the clawback reversal.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> RefundPayableClearing(
|
||||
long bookingId,
|
||||
long totalRefundedIrr,
|
||||
long refundId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
var group = Guid.NewGuid();
|
||||
return
|
||||
[
|
||||
Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Debit, totalRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt),
|
||||
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, totalRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> ClawbackWriteOff(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long amountIrr,
|
||||
long clawbackId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
var group = Guid.NewGuid();
|
||||
return
|
||||
[
|
||||
Leg(group, LedgerAccountType.BadDebt, LedgerDirection.Debit, amountIrr, null, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt),
|
||||
Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt)
|
||||
];
|
||||
}
|
||||
|
||||
private static LedgerEntry Leg(
|
||||
Guid group, string account, string direction, long amount, long? nurse,
|
||||
long bookingId, string sourceType, long sourceId, DateTime createdAt) => new()
|
||||
{
|
||||
TransactionGroupId = group,
|
||||
AccountType = account,
|
||||
Direction = direction,
|
||||
AmountIrr = amount,
|
||||
NurseId = nurse,
|
||||
BookingId = bookingId,
|
||||
SourceRefType = sourceType,
|
||||
SourceRefId = sourceId,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Baya.Domain.Entities.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>nurse_clawbacks.status</c> code set. This phase only ever creates rows in <see cref="Pending"/>
|
||||
/// (and supports an admin <see cref="WrittenOff"/>); <see cref="Recovered"/> is set by <b>b13</b>'s payout
|
||||
/// netting — recovery is not implemented here. Persisted as these stable snake_case codes.
|
||||
/// </summary>
|
||||
public static class ClawbackStatus
|
||||
{
|
||||
/// <summary>Open receivable owed by the nurse. The only state this phase creates.</summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>Netted out of a later payout batch. Set by b13 — never here.</summary>
|
||||
public const string Recovered = "recovered";
|
||||
|
||||
/// <summary>Admin-declared uncollectable; balanced by a <c>bad_debt</c> posting.</summary>
|
||||
public const string WrittenOff = "written_off";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// A first-class receivable opened when a booking is refunded/disputed <b>after</b> the nurse was already paid.
|
||||
/// Because an Iranian IBAN transfer is effectively irreversible, that money is already gone and must be
|
||||
/// recorded as owed-back — never silently absorbed. The receivable's ledger leg is
|
||||
/// <c>DEBIT nurse_clawback_receivable</c>; the balance derives from the ledger, this row tracks the workflow.
|
||||
/// <para>
|
||||
/// This phase only ever creates rows in <see cref="ClawbackStatus.Pending"/> and supports an admin write-off.
|
||||
/// <see cref="RecoveredInPayoutId"/> / <see cref="OriginalPayoutId"/> are the (nullable) join points b13 fills
|
||||
/// when a payout batch nets the clawback out — <c>nurse_payouts</c> arrives in b13.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NurseClawback : BaseEntity<long>
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
public long BookingId { get; set; }
|
||||
public long RefundId { get; set; }
|
||||
|
||||
/// <summary>The payout that already paid the nurse. FK target (<c>nurse_payouts</c>) arrives in b13; the
|
||||
/// column/index are in place now and the value is set once b13 exists.</summary>
|
||||
public long? OriginalPayoutId { get; set; }
|
||||
|
||||
/// <summary>Equals the refund's <c>nurse_payout_refunded_irr</c> leg (IRR).</summary>
|
||||
public long AmountIrr { get; set; }
|
||||
|
||||
public string Status { get; private set; } = ClawbackStatus.Pending;
|
||||
|
||||
/// <summary>The batch that netted it out. Set by <b>b13</b> only — always null here.</summary>
|
||||
public long? RecoveredInPayoutId { get; set; }
|
||||
|
||||
public DateTime? ResolvedAt { get; private set; }
|
||||
public string? ResolutionNotes { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public bool IsPending => Status == ClawbackStatus.Pending;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (Status != ClawbackStatus.Pending)
|
||||
throw new InvalidOperationException($"Only a pending clawback can be written off (was {Status}).");
|
||||
Status = ClawbackStatus.WrittenOff;
|
||||
ResolutionNotes = notes;
|
||||
ResolvedAt = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// An admin-initiated, ticket-linked reversal of a captured booking payment. Refunds are <b>1:N</b> per
|
||||
/// <c>payment_transaction</c> — partials exist (a shortened visit) — so the "Σ refunded ≤ captured" invariant
|
||||
/// is a <b>handler</b> check under the booking refund lock, not a single-row constraint.
|
||||
/// <para>
|
||||
/// A refund <b>decomposes across both fee legs</b>: <see cref="Amount"/> = <see cref="PlatformFeeRefundedIrr"/>
|
||||
/// (portion of the platform commission reversed) + <see cref="NursePayoutRefundedIrr"/> (portion of the nurse
|
||||
/// payout reversed — this leg drives a <c>nurse_clawbacks</c> receivable when the nurse was already paid).
|
||||
/// Money is IRR <c>BIGINT</c> only. The channel is chosen from the original payment type; the ledger legs are
|
||||
/// the same across channels — only <see cref="RefundChannel"/>, the external reference and the customer ETA
|
||||
/// differ.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Refund : BaseEntity<long>
|
||||
{
|
||||
/// <summary>The captured transaction being reversed (N:1 — a transaction may have several partial refunds).</summary>
|
||||
public long PaymentTransactionId { get; set; }
|
||||
|
||||
public long BookingId { get; set; }
|
||||
|
||||
/// <summary>The customer the refund is <i>for</i> (not the admin actor).</summary>
|
||||
public long RequestedByCustomerId { get; set; }
|
||||
|
||||
/// <summary>Forward-dep on <c>tickets</c> (b15). Nullable now; "ticket required" is a config-gated
|
||||
/// handler/validator rule so admin refunds are testable before b15 wires the real FK target.</summary>
|
||||
public long? TicketId { get; set; }
|
||||
|
||||
/// <summary>Total refunded (IRR) = <see cref="PlatformFeeRefundedIrr"/> + <see cref="NursePayoutRefundedIrr"/>.</summary>
|
||||
public long Amount { get; set; }
|
||||
|
||||
/// <summary>Portion of <c>balinyaar_commission_irr</c> being reversed (IRR).</summary>
|
||||
public long PlatformFeeRefundedIrr { get; set; }
|
||||
|
||||
/// <summary>Portion of <c>nurse_payout_amount</c> being reversed (IRR) — drives a clawback if already paid.</summary>
|
||||
public long NursePayoutRefundedIrr { get; set; }
|
||||
|
||||
/// <summary>The resolved refund fraction applied to the booking amounts to derive the legs (0–1).</summary>
|
||||
public decimal RefundPercentage { get; set; }
|
||||
|
||||
/// <summary>A <see cref="RefundChannel"/> code — how the money physically flows back.</summary>
|
||||
public string RefundChannel { get; set; } = null!;
|
||||
|
||||
public string? ReasonCategory { get; set; }
|
||||
public string? ReasonNotes { get; set; }
|
||||
|
||||
/// <summary>Guarded — mutated only through the cohesive methods so every write goes through the machine.</summary>
|
||||
public string Status { get; private set; } = RefundStatus.Approved;
|
||||
|
||||
public int? ApprovedByAdminId { get; set; }
|
||||
public string? RejectedReason { get; private set; }
|
||||
public string? AdminNotes { get; set; }
|
||||
|
||||
/// <summary>The PSP card-refund reference, when the channel is <c>psp_card</c>.</summary>
|
||||
public string? GatewayRefundReference { get; private set; }
|
||||
|
||||
/// <summary>The BNPL provider revert id, when the channel is <c>bnpl_revert</c> (or the manual bank ref).</summary>
|
||||
public string? ExternalRevertReference { get; private set; }
|
||||
|
||||
/// <summary>The ~7–10 business-day BNPL customer window; null for instant card refunds.</summary>
|
||||
public DateOnly? ExpectedCustomerRefundEta { get; private set; }
|
||||
|
||||
/// <summary>Snapshot of the b9 cancellation policy <c>code</c> that produced this refund — never re-resolved live.</summary>
|
||||
public string? CancellationPolicyCode { get; set; }
|
||||
|
||||
/// <summary>Snapshot of the resolved refund percentage (0–100) frozen at cancel time.</summary>
|
||||
public decimal? RefundPercentageApplied { get; set; }
|
||||
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public bool CanTransitionTo(string target) => RefundTransitions.CanTransition(Status, target);
|
||||
|
||||
private void Transition(string target)
|
||||
{
|
||||
if (!RefundTransitions.CanTransition(Status, target))
|
||||
throw new InvalidOperationException($"Illegal refund transition {Status} → {target}.");
|
||||
Status = target;
|
||||
}
|
||||
|
||||
/// <summary>Card path — the reversal is effectively immediate; no customer-facing ETA.</summary>
|
||||
public void MarkSucceededCard(string gatewayRefundReference, DateTime now)
|
||||
{
|
||||
Transition(RefundStatus.Succeeded);
|
||||
GatewayRefundReference = gatewayRefundReference;
|
||||
ExpectedCustomerRefundEta = null;
|
||||
ProcessedAt = now;
|
||||
}
|
||||
|
||||
/// <summary>BNPL/manual path — the provider revert is accepted but the customer cash-back is async; the
|
||||
/// refund waits in <c>processing</c> until reconciliation confirms it and surfaces the ETA meanwhile.</summary>
|
||||
public void MarkProcessing(string? externalRevertReference, DateOnly? expectedCustomerRefundEta)
|
||||
{
|
||||
Transition(RefundStatus.Processing);
|
||||
ExternalRevertReference = externalRevertReference;
|
||||
ExpectedCustomerRefundEta = expectedCustomerRefundEta;
|
||||
}
|
||||
|
||||
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual).</summary>
|
||||
public void MarkSucceededAsync(DateTime now)
|
||||
{
|
||||
Transition(RefundStatus.Succeeded);
|
||||
ProcessedAt = now;
|
||||
}
|
||||
|
||||
public void MarkFailed(string? reason)
|
||||
{
|
||||
Transition(RefundStatus.Failed);
|
||||
RejectedReason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Baya.Domain.Entities.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>refunds.refund_channel</c> code set — <b>how the money physically flows back</b>. The ledger
|
||||
/// legs are identical across channels (see <c>LedgerPosting.RefundReversal</c>); only the execution + metadata
|
||||
/// differ. The data-model doc writes the out-of-band bank code as <c>manual_bank</c>; the canonical <b>wire</b>
|
||||
/// code is <see cref="Manual"/> (per <c>dev/contracts/conventions/money-and-types.md</c>) — they are the same
|
||||
/// channel, and this is the value stored and serialized.
|
||||
/// </summary>
|
||||
public static class RefundChannel
|
||||
{
|
||||
/// <summary>PSP card reversal — effectively immediate, no customer-facing ETA.</summary>
|
||||
public const string PspCard = "psp_card";
|
||||
|
||||
/// <summary>BNPL provider revert/update — async, surfaces a ~7–10 business-day customer ETA.</summary>
|
||||
public const string BnplRevert = "bnpl_revert";
|
||||
|
||||
/// <summary>Out-of-band bank refund the admin executes and records the reference for (data-model: <c>manual_bank</c>).</summary>
|
||||
public const string Manual = "manual";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Baya.Domain.Entities.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>refunds.status</c> code set — a <b>forward-only</b> lifecycle. A card refund is effectively
|
||||
/// immediate (<see cref="Approved"/> → <see cref="Succeeded"/>); a BNPL revert sits in <see cref="Processing"/>
|
||||
/// until the async 7–10-business-day customer cash-back is reconciled. Persisted as these stable snake_case
|
||||
/// codes; the allowed edges live in <see cref="RefundTransitions"/>.
|
||||
/// </summary>
|
||||
public static class RefundStatus
|
||||
{
|
||||
/// <summary>Recorded but not yet approved. Reserved — admin refunds are created already approved today.</summary>
|
||||
public const string Requested = "requested";
|
||||
|
||||
/// <summary>Admin-approved; the channel execution + ledger posting run under the same lock.</summary>
|
||||
public const string Approved = "approved";
|
||||
|
||||
/// <summary>Channel accepted but the customer cash-back is not yet confirmed (the BNPL/manual wait state).</summary>
|
||||
public const string Processing = "processing";
|
||||
|
||||
/// <summary>The customer refund is confirmed (card immediately; BNPL on reconciliation). Terminal.</summary>
|
||||
public const string Succeeded = "succeeded";
|
||||
|
||||
/// <summary>The channel refused the reversal. Terminal — a fresh attempt is a new refund row.</summary>
|
||||
public const string Failed = "failed";
|
||||
|
||||
/// <summary>The refund was declined by the admin before any money moved. Terminal.</summary>
|
||||
public const string Rejected = "rejected";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Baya.Domain.Entities.Refunds;
|
||||
|
||||
/// <summary>
|
||||
/// The forward-only allowed-edge table for the <see cref="RefundStatus"/> machine. Every write goes through
|
||||
/// <see cref="Refund"/>'s cohesive methods, which assert the edge here — an illegal transition is a
|
||||
/// programming error (the handler pre-checks the expected cases), so the entity fails fast rather than
|
||||
/// silently overwriting a terminal state.
|
||||
/// </summary>
|
||||
public static class RefundTransitions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
|
||||
new Dictionary<string, IReadOnlyCollection<string>>
|
||||
{
|
||||
[RefundStatus.Requested] = [RefundStatus.Approved, RefundStatus.Rejected],
|
||||
[RefundStatus.Approved] = [RefundStatus.Processing, RefundStatus.Succeeded, RefundStatus.Failed, RefundStatus.Rejected],
|
||||
[RefundStatus.Processing] = [RefundStatus.Succeeded, RefundStatus.Failed],
|
||||
[RefundStatus.Succeeded] = [],
|
||||
[RefundStatus.Failed] = [],
|
||||
[RefundStatus.Rejected] = []
|
||||
};
|
||||
|
||||
public static bool CanTransition(string from, string to)
|
||||
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
|
||||
}
|
||||
@@ -45,9 +45,13 @@ public static class SupportAlertType
|
||||
public const string PaymentAnomaly = "payment_anomaly";
|
||||
public const string FraudSignal = "fraud_signal";
|
||||
|
||||
/// <summary>A refund on an already-paid booking opened a nurse clawback receivable (b11) — staff must
|
||||
/// track recovery. Iranian IBAN transfers are irreversible, so this is always worth a human look.</summary>
|
||||
public const string NurseClawback = "nurse_clawback";
|
||||
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal
|
||||
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user