backend phase 11

This commit is contained in:
hamid
2026-07-09 02:13:30 +03:30
parent 23605591eb
commit 465f75c29e
78 changed files with 9555 additions and 27 deletions
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.WriteOffClawback;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>Admin-only nurse-clawback management. Write-off marks a pending receivable uncollectable and posts
/// the balancing bad-debt correction; recovery via payout netting is b13.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_clawbacks")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin nurse-clawback write-off")]
public sealed class AdminClawbacksController(ISender sender) : BaseController
{
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> WriteOff(long id, WriteOffClawbackBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new WriteOffClawbackCommand(id, body.Reason), cancellationToken));
}
/// <summary>The write-off body (the id comes from the route).</summary>
public record WriteOffClawbackBody(string Reason);
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Invoices.Commands.IssueInvoice;
using Baya.Application.Models.Invoices;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>Admin-only invoice issuance. Issuing an invoice draws the next sequential number, computes VAT on
/// the commission line from config, and submits to (mocked) مودیان. Idempotent per booking.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_invoices")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin invoice issuance")]
public sealed class AdminInvoicesController(ISender sender) : BaseController
{
[HttpPost]
[ProducesOkApiResponseType<InvoiceDto>]
public async Task<IActionResult> Issue(IssueInvoiceCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Queries.ListRefunds;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin-only refund console. Creating a refund reverses a captured booking payment across both fee legs, posts
/// the balanced ledger reversal, forks on whether the nurse was already paid (clawback), and is rate-limited as
/// a money endpoint. There is no customer refund-initiation path.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_refunds")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin refund creation + worklist")]
public sealed class AdminRefundsController(ISender sender) : BaseController
{
[HttpPost]
[ProducesOkApiResponseType<CreateRefundResult>]
public async Task<IActionResult> Create(CreateRefundCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet]
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Invoices.Queries.GetInvoice;
using Baya.Application.Models.Invoices;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>The booking's invoice for the customer (tenancy-scoped) or an admin. Read-only.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/invoices")]
[Authorize]
[Display(Description = "Booking invoice (customer/admin)")]
public sealed class InvoicesController(ISender sender) : BaseController
{
[HttpGet("{bookingId}")]
[ProducesOkApiResponseType<InvoiceDto>]
public async Task<IActionResult> Get(long bookingId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetInvoiceQuery(bookingId), cancellationToken));
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Queries.GetRefundStatus;
using Baya.Application.Models.Refunds;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>The customer-facing refund status — the only customer-visible refund surface (there is no
/// self-service refund initiation). Tenancy-scoped: a customer sees only their own refund.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/refunds")]
[Authorize]
[Display(Description = "Customer refund status")]
public sealed class RefundsController(ISender sender) : BaseController
{
[HttpGet("{id}/status")]
[ProducesOkApiResponseType<RefundStatusDto>]
public async Task<IActionResult> Status(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken));
}
@@ -0,0 +1,23 @@
namespace Baya.Application.Common;
/// <summary>
/// Business-day arithmetic for customer-facing ETAs (the BNPL 710-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();
}
@@ -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;
}
@@ -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);
}
}
@@ -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);
}
@@ -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 });
}
}
@@ -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>>;
@@ -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 0100 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);
}
}
@@ -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);
}
}
@@ -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 01 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>>;
@@ -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);
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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 710-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 (01).</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 ~710 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 (0100) 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 ~710 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 710-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
];
}
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A thin, deterministic mock <see cref="IBnplProvider"/> so b11's <c>bnpl_revert</c> refund path is exercised
/// before b12 merges — <b>b12 owns the real seam definition and its full adapter</b> (SnappPay/Tara). Revert
/// and update both succeed, echo a deterministic <c>external_revert_reference</c> derived from the order +
/// idempotency key, and report a nullable provider commission reversal (null by default — some providers keep
/// their fee on a refund; the amount is reconciled from the response, never hardcoded).
/// </summary>
public sealed class MockBnplProvider(IOptions<SeamOptions> options) : IBnplProvider
{
private readonly BnplOptions _options = options.Value.Bnpl;
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
private ValueTask<BnplRevertResult> Result(string providerOrderReference, string idempotencyKey)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null));
return ValueTask.FromResult(new BnplRevertResult(
PaymentProviderStatus.Succeeded,
ExternalRevertReference: $"mock-bnpl-revert-{providerOrderReference}-{idempotencyKey}",
ProviderCommissionReversedAmount: _options.ReverseProviderCommission ? 0 : null));
}
}
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Contracts.Invoices;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IMoadianClient"/> (b11) — no external call. By default a submission leaves the
/// invoice at <c>moadian_status = pending</c> with no reference (the real reconciliation later flips it to
/// <c>registered</c>). Set <see cref="MoadianOptions.ForceRegistered"/> to have it return <c>registered</c> with
/// a deterministic fake 22-digit reference so the <c>registered</c>/reconciliation path is testable. The real
/// سامانه مودیان adapter (enrollment, the معاملات/invoice submission API, the 22-digit reference) swaps only this
/// registration.
/// </summary>
public sealed class MockMoadianClient(IOptions<SeamOptions> options) : IMoadianClient
{
private readonly MoadianOptions _options = options.Value.Moadian;
public ValueTask<MoadianSubmissionResult> SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default)
{
if (!_options.ForceRegistered)
return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Pending, null));
// A deterministic 22-digit reference derived from the booking id (right-aligned, zero-padded).
var reference = submission.BookingId.ToString().PadLeft(22, '0');
return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Registered, reference));
}
}
@@ -24,6 +24,6 @@ public sealed class MockPaymentProvider : IPaymentProvider
public ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}"));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}-{idempotencyKey}"));
}
@@ -16,6 +16,34 @@ public sealed class SeamOptions
public IdentityKycOptions IdentityKyc { get; set; } = new();
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
public PaymentsOptions Payments { get; set; } = new();
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IMoadianClient</c> (b11 e-invoicing). By default a submission stays <c>pending</c> with no
/// reference. Set <see cref="ForceRegistered"/> to make it return <c>registered</c> with a fake 22-digit ref so
/// the reconciliation/registered path is testable. The real سامانه مودیان adapter ignores these.
/// </summary>
public sealed class MoadianOptions
{
/// <summary>When true, a submission returns <c>registered</c> + a deterministic fake 22-digit reference.</summary>
public bool ForceRegistered { get; set; }
}
/// <summary>
/// Tunes the thin local mock <c>IBnplProvider</c> b11 registers until b12 ships the real seam. By default a
/// revert/update succeeds and the provider keeps its commission (null reversal). The real b12 adapter ignores
/// these.
/// </summary>
public sealed class BnplOptions
{
/// <summary>When true, every revert/update fails so the refund-channel-refused path is testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>When true, the mock reports the provider returned its commission (a non-null, zero reversal
/// placeholder) so the <c>provider_commission_reversed_amount</c> reconciliation is exercised.</summary>
public bool ReverseProviderCommission { get; set; }
}
/// <summary>
@@ -1,4 +1,5 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments;
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration;
@@ -58,6 +59,12 @@ public static class ServiceCollectionExtension
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered). IBnplProvider is a thin local stub so the bnpl_revert refund
// path runs before b12 merges — b12 owns the real seam. Both swap in by a registration change only.
services.AddSingleton<IMoadianClient, MockMoadianClient>();
services.AddSingleton<IBnplProvider, MockBnplProvider>();
return services;
}
}
@@ -46,6 +46,9 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
];
return rows
@@ -0,0 +1,34 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential
/// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the
/// commission line only. <c>partner_center_id</c> is a nullable column with <b>no FK</b> — <c>partner_centers</c>
/// is a forward-dep on b15.
/// </summary>
internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> builder)
{
builder.ToTable("Invoices", "payments");
builder.Property(i => i.InvoiceNumber).HasMaxLength(40).IsRequired();
builder.Property(i => i.IssuingEntityType).HasMaxLength(20).IsRequired();
builder.Property(i => i.MoadianReferenceNumber).HasMaxLength(40);
builder.Property(i => i.MoadianStatus).HasMaxLength(20);
builder.Property(i => i.PdfStorageKey).HasMaxLength(512);
builder.Property(i => i.VatRate).HasPrecision(5, 4);
builder.HasIndex(i => i.InvoiceNumber).IsUnique();
builder.HasIndex(i => i.BookingId).IsUnique();
builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired();
builder.HasQueryFilter(i => i.DeletedAt == null);
}
}
@@ -0,0 +1,23 @@
using Baya.Domain.Entities.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// The single-row counter behind the sequential <c>invoice_number</c>. Seeded with one row (id 1, next = 1) so
/// <c>EnsureCreated</c> (tests) and the migration both start the sequence. The id is fixed (never generated) —
/// there is exactly one counter. Portable across SQL Server and SQLite (no provider-specific DB sequence).
/// </summary>
internal sealed class InvoiceNumberSequenceConfig : IEntityTypeConfiguration<InvoiceNumberSequence>
{
public void Configure(EntityTypeBuilder<InvoiceNumberSequence> builder)
{
builder.ToTable("InvoiceNumberSequences", "payments");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).ValueGeneratedNever();
builder.HasData(new InvoiceNumberSequence { Id = 1, NextValue = 1 });
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable columns + indexes now
/// with <b>no FK</b> — <c>nurse_payouts</c> is a forward-dep on b13, which sets the values and wires the FKs.
/// </summary>
internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback>
{
public void Configure(EntityTypeBuilder<NurseClawback> builder)
{
builder.ToTable("NurseClawbacks", "payments");
builder.Property(c => c.Status).HasMaxLength(30).IsRequired();
builder.Property(c => c.ResolutionNotes).HasMaxLength(500);
builder.HasIndex(c => c.NurseId);
builder.HasIndex(c => c.BookingId);
builder.HasIndex(c => c.RefundId).IsUnique();
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.OriginalPayoutId);
builder.HasIndex(c => c.RecoveredInPayoutId);
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired();
builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,50 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation
/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a
/// single-row constraint). <c>ticket_id</c> is a nullable column + index now with <b>no FK</b> — the
/// <c>tickets</c> table is a forward-dep on b15, which wires the real FK target.
/// </summary>
internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
{
public void Configure(EntityTypeBuilder<Refund> builder)
{
builder.ToTable("Refunds", "payments", t => t.HasCheckConstraint(
"CK_Refunds_LegSplit",
"[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] " +
"AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"));
builder.Property(r => r.RefundChannel).HasMaxLength(20).IsRequired();
builder.Property(r => r.Status).HasMaxLength(20).IsRequired();
builder.Property(r => r.ReasonCategory).HasMaxLength(50);
builder.Property(r => r.ReasonNotes).HasMaxLength(1000);
builder.Property(r => r.AdminNotes).HasMaxLength(1000);
builder.Property(r => r.RejectedReason).HasMaxLength(500);
builder.Property(r => r.GatewayRefundReference).HasMaxLength(200);
builder.Property(r => r.ExternalRevertReference).HasMaxLength(200);
builder.Property(r => r.CancellationPolicyCode).HasMaxLength(50);
builder.Property(r => r.RefundPercentage).HasPrecision(6, 4);
builder.Property(r => r.RefundPercentageApplied).HasPrecision(5, 2);
builder.HasIndex(r => r.PaymentTransactionId);
builder.HasIndex(r => r.BookingId);
builder.HasIndex(r => r.RequestedByCustomerId);
builder.HasIndex(r => r.Status);
// Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist).
builder.HasIndex(r => r.TicketId);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired();
builder.HasQueryFilter(r => r.DeletedAt == null);
}
}
@@ -0,0 +1,313 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefundsClawbacksInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InvoiceNumberSequences",
schema: "payments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
NextValue = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceNumberSequences", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Invoices",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
InvoiceNumber = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
IssuingEntityType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
GrossIrr = table.Column<long>(type: "bigint", nullable: false),
PlatformCommissionIrr = table.Column<long>(type: "bigint", nullable: false),
BnplCommissionIrr = table.Column<long>(type: "bigint", nullable: true),
VatRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false),
VatIrr = table.Column<long>(type: "bigint", nullable: false),
MoadianReferenceNumber = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
MoadianStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
PdfStorageKey = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
IssuedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Invoices", x => x.Id);
table.ForeignKey(
name: "FK_Invoices_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Refunds",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PaymentTransactionId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
RequestedByCustomerId = table.Column<long>(type: "bigint", nullable: false),
TicketId = table.Column<long>(type: "bigint", nullable: true),
Amount = table.Column<long>(type: "bigint", nullable: false),
PlatformFeeRefundedIrr = table.Column<long>(type: "bigint", nullable: false),
NursePayoutRefundedIrr = table.Column<long>(type: "bigint", nullable: false),
RefundPercentage = table.Column<decimal>(type: "decimal(6,4)", precision: 6, scale: 4, nullable: false),
RefundChannel = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ReasonCategory = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
ReasonNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ApprovedByAdminId = table.Column<int>(type: "int", nullable: true),
RejectedReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
AdminNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
GatewayRefundReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExternalRevertReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExpectedCustomerRefundEta = table.Column<DateOnly>(type: "date", nullable: true),
CancellationPolicyCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RefundPercentageApplied = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Refunds", x => x.Id);
table.CheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0");
table.ForeignKey(
name: "FK_Refunds_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Refunds_CustomerProfiles_RequestedByCustomerId",
column: x => x.RequestedByCustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Refunds_PaymentTransactions_PaymentTransactionId",
column: x => x.PaymentTransactionId,
principalSchema: "payments",
principalTable: "PaymentTransactions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseClawbacks",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
RefundId = table.Column<long>(type: "bigint", nullable: false),
OriginalPayoutId = table.Column<long>(type: "bigint", nullable: true),
AmountIrr = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
RecoveredInPayoutId = table.Column<long>(type: "bigint", nullable: true),
ResolvedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ResolutionNotes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseClawbacks", x => x.Id);
table.ForeignKey(
name: "FK_NurseClawbacks_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseClawbacks_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseClawbacks_Refunds_RefundId",
column: x => x.RefundId,
principalSchema: "payments",
principalTable: "Refunds",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "payments",
table: "InvoiceNumberSequences",
columns: new[] { "Id", "NextValue" },
values: new object[] { 1, 1L });
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" },
{ 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Business days shown as the customer BNPL refund ETA (b11).", "bnpl_refund_eta_business_days", null, null, "10" },
{ 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", "refund_assume_nurse_paid", null, null, "false" }
});
migrationBuilder.CreateIndex(
name: "IX_Invoices_BookingId",
schema: "payments",
table: "Invoices",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Invoices_InvoiceNumber",
schema: "payments",
table: "Invoices",
column: "InvoiceNumber",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_BookingId",
schema: "payments",
table: "NurseClawbacks",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_NurseId",
schema: "payments",
table: "NurseClawbacks",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "OriginalPayoutId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "RecoveredInPayoutId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_RefundId",
schema: "payments",
table: "NurseClawbacks",
column: "RefundId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_Status",
schema: "payments",
table: "NurseClawbacks",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Refunds_BookingId",
schema: "payments",
table: "Refunds",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_PaymentTransactionId",
schema: "payments",
table: "Refunds",
column: "PaymentTransactionId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_RequestedByCustomerId",
schema: "payments",
table: "Refunds",
column: "RequestedByCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_Status",
schema: "payments",
table: "Refunds",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Refunds_TicketId",
schema: "payments",
table: "Refunds",
column: "TicketId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InvoiceNumberSequences",
schema: "payments");
migrationBuilder.DropTable(
name: "Invoices",
schema: "payments");
migrationBuilder.DropTable(
name: "NurseClawbacks",
schema: "payments");
migrationBuilder.DropTable(
name: "Refunds",
schema: "payments");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 19L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 20L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 21L);
}
}
}
@@ -1155,6 +1155,33 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).",
Key = "no_show_scan_cadence_hours",
Value = "1"
},
new
{
Id = 19L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.",
Key = "refund_ticket_required",
Value = "false"
},
new
{
Id = 20L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Business days shown as the customer BNPL refund ETA (b11).",
Key = "bnpl_refund_eta_business_days",
Value = "10"
},
new
{
Id = 21L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.",
Key = "refund_assume_nurse_paid",
Value = "false"
});
});
@@ -2630,6 +2657,107 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Patients", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BnplCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("GrossIrr")
.HasColumnType("bigint");
b.Property<string>("InvoiceNumber")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTime>("IssuedAt")
.HasColumnType("datetime2");
b.Property<string>("IssuingEntityType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("MoadianReferenceNumber")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("MoadianStatus")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<string>("PdfStorageKey")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<long>("PlatformCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("VatIrr")
.HasColumnType("bigint");
b.Property<decimal>("VatRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.HasIndex("InvoiceNumber")
.IsUnique();
b.ToTable("Invoices", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<long>("NextValue")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("InvoiceNumberSequences", "payments");
b.HasData(
new
{
Id = 1,
NextValue = 1L
});
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
@@ -2949,6 +3077,194 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("PaymentWebhookEvents", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("AmountIrr")
.HasColumnType("bigint");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long?>("OriginalPayoutId")
.HasColumnType("bigint");
b.Property<long?>("RecoveredInPayoutId")
.HasColumnType("bigint");
b.Property<long>("RefundId")
.HasColumnType("bigint");
b.Property<string>("ResolutionNotes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("NurseId");
b.HasIndex("OriginalPayoutId");
b.HasIndex("RecoveredInPayoutId");
b.HasIndex("RefundId")
.IsUnique();
b.HasIndex("Status");
b.ToTable("NurseClawbacks", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AdminNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<int?>("ApprovedByAdminId")
.HasColumnType("int");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<string>("CancellationPolicyCode")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateOnly?>("ExpectedCustomerRefundEta")
.HasColumnType("date");
b.Property<string>("ExternalRevertReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("GatewayRefundReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NursePayoutRefundedIrr")
.HasColumnType("bigint");
b.Property<long>("PaymentTransactionId")
.HasColumnType("bigint");
b.Property<long>("PlatformFeeRefundedIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("ReasonCategory")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ReasonNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RefundChannel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal>("RefundPercentage")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<decimal?>("RefundPercentageApplied")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<string>("RejectedReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<long>("RequestedByCustomerId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long?>("TicketId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("PaymentTransactionId");
b.HasIndex("RequestedByCustomerId");
b.HasIndex("Status");
b.HasIndex("TicketId");
b.ToTable("Refunds", "payments", t =>
{
t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.Property<long>("Id")
@@ -4186,6 +4502,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("Customer");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -4231,6 +4556,48 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany()
.HasForeignKey("RefundId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null)
.WithMany()
.HasForeignKey("PaymentTransactionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
.WithMany()
.HasForeignKey("RequestedByCustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
@@ -23,6 +23,8 @@ public class UnitOfWork : IUnitOfWork
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -44,6 +46,8 @@ public class UnitOfWork : IUnitOfWork
BookingRepository = new BookingRepository(_db);
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
PaymentRepository = new PaymentRepository(_db);
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,67 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Invoices;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class InvoiceRepository : BaseAsyncRepository<Invoice>, IInvoiceRepository
{
public InvoiceRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<InvoiceBookingAmounts?> GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken)
=> (from b in DbContext.Set<Booking>().AsNoTracking()
where b.Id == bookingId
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new InvoiceBookingAmounts(
b.Id,
c.UserId,
b.GrossPriceIrr,
b.BalinyaarCommissionIrr,
(long?)null))
.FirstOrDefaultAsync(cancellationToken);
public Task<Invoice?> GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(i => i.BookingId == bookingId, cancellationToken);
public async Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken)
{
var row = await (from i in TableNoTracking
where i.BookingId == bookingId
join b in DbContext.Set<Booking>() on i.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new { CustomerUserId = c.UserId, Invoice = i })
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
// The PDF URL is resolved by the handler through IObjectStorage — the repo carries the raw key.
var dto = new InvoiceDto(
row.Invoice.Id, row.Invoice.BookingId, row.Invoice.InvoiceNumber, row.Invoice.IssuingEntityType,
row.Invoice.GrossIrr.ToString(), row.Invoice.PlatformCommissionIrr.ToString(),
row.Invoice.BnplCommissionIrr?.ToString(), row.Invoice.VatRate, row.Invoice.VatIrr.ToString(),
row.Invoice.MoadianReferenceNumber, row.Invoice.MoadianStatus, PdfUrl: null, row.Invoice.IssuedAt);
return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto);
}
public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
=> base.AddAsync(invoice);
public async Task<long> ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken)
{
var counter = await DbContext.Set<InvoiceNumberSequence>().FirstOrDefaultAsync(s => s.Id == 1, cancellationToken)
?? throw new InvalidOperationException("The invoice-number counter row is missing.");
var reserved = counter.NextValue;
counter.NextValue = reserved + 1;
return reserved;
}
}
@@ -0,0 +1,144 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRepository
{
public RefundRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<RefundMoneyContext?> GetRefundContextAsync(long bookingId, CancellationToken cancellationToken)
=> (from t in DbContext.Set<PaymentTransaction>().AsNoTracking()
where t.BookingId == bookingId && t.Status == PaymentTransactionStatus.Succeeded
join b in DbContext.Set<Booking>() on t.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
join g in DbContext.Set<PaymentGateway>() on t.GatewayId equals g.Id
select new RefundMoneyContext(
b.Id,
b.CustomerId,
c.UserId,
b.NurseId,
b.GrossPriceIrr,
b.BalinyaarCommissionIrr,
b.NursePayoutAmount,
b.CancellationPolicyCode,
b.CancellationRefundPercentage,
b.RefundableAmountIrr,
t.Id,
t.GatewayReferenceCode,
t.Amount,
g.Type))
.FirstOrDefaultAsync(cancellationToken);
public async Task<long> GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> await TableNoTracking
.Where(r => r.PaymentTransactionId == paymentTransactionId
&& r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected)
.SumAsync(r => (long?)r.Amount, cancellationToken) ?? 0;
public Task AddRefundAsync(Refund refund, CancellationToken cancellationToken)
=> base.AddAsync(refund);
public Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().AddAsync(clawback, cancellationToken).AsTask();
public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking;
if (bookingId is { } bid)
query = query.Where(r => r.BookingId == bid);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(r => r.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(r => new
{
r.Id,
r.BookingId,
r.PaymentTransactionId,
r.Amount,
r.PlatformFeeRefundedIrr,
r.NursePayoutRefundedIrr,
r.RefundChannel,
r.Status,
r.RefundPercentage,
r.ReasonCategory,
r.CancellationPolicyCode,
r.RefundPercentageApplied,
r.ExpectedCustomerRefundEta,
r.GatewayRefundReference,
r.ExternalRevertReference,
r.ProcessedAt,
r.CreatedAt
})
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new RefundListItemDto(
r.Id, r.BookingId, r.PaymentTransactionId,
r.Amount.ToString(), r.PlatformFeeRefundedIrr.ToString(), r.NursePayoutRefundedIrr.ToString(),
r.RefundChannel, r.Status, r.RefundPercentage, r.ReasonCategory,
r.CancellationPolicyCode, r.RefundPercentageApplied, r.ExpectedCustomerRefundEta,
r.GatewayRefundReference, r.ExternalRevertReference, r.ProcessedAt, r.CreatedAt))
.ToList();
return new PagedResult<RefundListItemDto>(items, total, page, pageSize);
}
public async Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken)
{
var row = await (from r in TableNoTracking
where r.Id == id
join b in DbContext.Set<Booking>() on r.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new
{
c.UserId,
r.Id,
r.BookingId,
r.Status,
r.RefundChannel,
r.Amount,
r.ExpectedCustomerRefundEta,
r.GatewayRefundReference,
r.ExternalRevertReference
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
var reference = Mask(row.GatewayRefundReference ?? row.ExternalRevertReference);
var dto = new RefundStatusDto(row.Id, row.BookingId, row.Status, row.RefundChannel, row.Amount.ToString(),
row.ExpectedCustomerRefundEta, reference);
return new RefundStatusProjection(row.UserId, dto);
}
// Show only the last 4 characters of an external reference to the customer — never the full PSP/BNPL id.
private static string? Mask(string? reference)
{
if (string.IsNullOrEmpty(reference))
return reference;
return reference.Length <= 4
? new string('•', reference.Length)
: $"{new string('•', reference.Length - 4)}{reference[^4..]}";
}
}
@@ -4,6 +4,7 @@ using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
@@ -16,6 +17,7 @@ using Baya.Infrastructure.Persistence.Services.Booking;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Payments;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
using Microsoft.AspNetCore.Builder;
@@ -52,6 +54,11 @@ public static class ServiceCollectionExtensions
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real
// impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps
// this registration for the authoritative payout-link lookup.
services.AddScoped<INursePayoutStatus, NursePayoutStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
@@ -0,0 +1,35 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The interim implementation of <see cref="INursePayoutStatus"/> until b13 ships <c>nurse_payouts</c> /
/// <c>nurse_payout_booking_links</c>. It derives "already paid?" from the booking's dispute-window close — the
/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path
/// is the fallback. A <c>refund_assume_nurse_paid</c> config switch forces the paid answer for ops/testing. b13
/// swaps this registration for the authoritative payout-link lookup.
/// </summary>
internal sealed class NursePayoutStatusService(
ApplicationDbContext dbContext,
IDateTimeProvider dateTimeProvider,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
var now = dateTimeProvider.UtcNow.UtcDateTime;
var windowEnd = await dbContext.Set<BookingEntity>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => b.DisputeWindowEndsAt)
.FirstOrDefaultAsync(cancellationToken);
return windowEnd is { } endsAt && endsAt <= now;
}
}
@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class AdminInvoicesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Issue_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId = 1 });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Issue_computes_vat_on_commission_and_returns_sequential_number()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901501");
var bookingId = await AdminRefundsApiTests.SeedCapturedBookingAsync(factory, "09131901502");
var issue = await admin.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId });
Assert.Equal(HttpStatusCode.OK, issue.StatusCode);
var data = await AuthTestClient.ReadDataAsync(issue);
Assert.False(string.IsNullOrEmpty(data.GetProperty("invoiceNumber").GetString()));
Assert.Equal("1500000", data.GetProperty("platformCommissionIrr").GetString());
Assert.Equal("150000", data.GetProperty("vatIrr").GetString()); // 10% of the commission line
Assert.Equal("pending", data.GetProperty("moadianStatus").GetString());
// Re-issue is idempotent — same number, no second invoice.
var reissue = await admin.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId });
var reissued = await AuthTestClient.ReadDataAsync(reissue);
Assert.Equal(data.GetProperty("invoiceNumber").GetString(), reissued.GetProperty("invoiceNumber").GetString());
// The booking's invoice is readable via the customer/admin GET.
var get = await admin.GetAsync($"/api/v1/invoices/{bookingId}");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
}
}
@@ -0,0 +1,175 @@
using System.Net;
using System.Net.Http.Json;
using Baya.Application.Contracts.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Api;
public class AdminRefundsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Create_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId = 1, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Create_InvalidBookingId_Returns400()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901401");
var response = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId = 0, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Create_full_refund_posts_balanced_reversal_then_over_refund_conflicts()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901402");
var bookingId = await SeedCapturedBookingAsync(factory, "09131901403");
var create = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var data = await AuthTestClient.ReadDataAsync(create);
Assert.Equal("succeeded", data.GetProperty("status").GetString());
Assert.Equal("10000000", data.GetProperty("amount").GetString());
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var legs = db.Set<LedgerEntry>().AsNoTracking()
.Where(l => l.BookingId == bookingId && l.SourceRefType == LedgerSourceRefType.Refund).ToList();
Assert.Equal(5, legs.Count);
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
}
// A second refund on the fully-refunded booking pushes over captured → 409.
var again = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 0.5m });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task List_AsAdmin_ReturnsPagedEnvelope()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901404");
var response = await admin.GetAsync("/api/v1/admin_refunds?status=succeeded&page=1&pageSize=20");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.True(data.GetProperty("total").GetInt32() >= 0);
}
/// <summary>Seeds a confirmed booking (gross 10M, commission 1.5M) with a captured card transaction, owned
/// by the given customer phone. Returns the booking id.</summary>
internal static async Task<long> SeedCapturedBookingAsync(BayaApiFactory factory, string customerPhone)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
if (customerUser is null)
{
await userManager.CreateUser(new User { UserName = $"cust_{Guid.NewGuid():N}", PhoneNumber = customerPhone });
customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
}
var customer = db.Set<CustomerProfile>().FirstOrDefault(c => c.UserId == customerUser!.Id);
if (customer is null)
{
customer = new CustomerProfile { UserId = customerUser!.Id };
db.Set<CustomerProfile>().Add(customer);
db.SaveChanges();
}
var province = new Province { NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
db.Set<Province>().Add(province);
db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "س", NameEn = "E", SortOrder = 1, IsActive = true };
db.Set<ServiceCategory>().Add(category);
db.SaveChanges();
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پ", FirstName = "ح", LastName = "ر", Gender = "male", IsActive = true };
db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خ", AddressLine = "خیابان",
PostalCode = "1111111111", RecipientName = "ع", RecipientPhone = customerPhone, IsPrimary = true
};
db.Set<CustomerAddress>().Add(address);
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", Gender = "female", Name = "ز", FamilyName = "ا", IsActive = true };
db.Users.Add(nurseUser);
db.SaveChanges();
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
db.Set<NurseProfile>().Add(nurse);
db.SaveChanges();
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 10_000_000, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
db.Set<NurseServiceVariant>().Add(variant);
db.SaveChanges();
var request = new BookingRequest
{
CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id,
CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "n", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
db.Set<BookingRequest>().Add(request);
db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
db.SaveChanges();
var booking = new BookingEntity
{
BookingRequestId = request.Id, CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id,
VariantId = variant.Id, CustomerAddressId = address.Id, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m,
NursePayoutAmount = 8_500_000, SessionCount = 1,
ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
};
booking.TransitionTo(BookingStatus.Confirmed, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc));
db.Set<BookingEntity>().Add(booking);
db.SaveChanges();
var gateway = new PaymentGateway { ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 };
db.Set<PaymentGateway>().Add(gateway);
db.SaveChanges();
var txn = new PaymentTransaction
{
BookingRequestId = request.Id, CustomerId = customer.Id, GatewayId = gateway.Id,
Amount = 10_000_000, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}"
};
txn.MarkSucceeded(booking.Id, "ok", null);
db.Set<PaymentTransaction>().Add(txn);
db.SaveChanges();
return booking.Id;
}
}
@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class RefundStatusApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task RefundStatus_is_visible_to_owner_and_hidden_from_another_customer()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901601");
var bookingId = await AdminRefundsApiTests.SeedCapturedBookingAsync(factory, "09131901602");
var create = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var refundId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("refundId").GetInt64();
// The owning customer sees their refund status + amount.
var owner = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, owner, "09131901602", "customer");
var ownerResponse = await owner.GetAsync($"/api/v1/refunds/{refundId}/status");
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
var ownerData = await AuthTestClient.ReadDataAsync(ownerResponse);
Assert.Equal("10000000", ownerData.GetProperty("amount").GetString());
// A different customer cannot — a cross-customer read is a clean not-found.
var other = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, other, "09131901603", "customer");
var otherResponse = await other.GetAsync($"/api/v1/refunds/{refundId}/status");
Assert.Equal(HttpStatusCode.NotFound, otherResponse.StatusCode);
}
[Fact]
public async Task RefundStatus_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/refunds/1/status");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,73 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Features.Invoices.Commands.IssueInvoice;
using Baya.Domain.Entities.Invoices;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
public class InvoiceHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static IssueInvoiceCommandHandler Handler(RefundsTestHost host, decimal vatRate = 0.10m)
{
var moadian = Substitute.For<IMoadianClient>();
moadian.SubmitAsync(Arg.Any<InvoiceSubmission>(), Arg.Any<CancellationToken>())
.Returns(new MoadianSubmissionResult(MoadianStatus.Pending, null));
var storage = Substitute.For<IObjectStorage>();
return new IssueInvoiceCommandHandler(
host.UnitOfWork, host.Config(vatRate: vatRate), host.Clock(Now), host.Lock(), moadian, storage);
}
[Fact]
public async Task Issue_computes_vat_on_commission_and_numbers_sequentially()
{
using var host = new RefundsTestHost();
var (bookingA, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var (bookingB, _) = host.SeedCapturedBooking(gross: 20_000_000, commission: 3_000_000);
var handler = Handler(host);
var a = await handler.Handle(new IssueInvoiceCommand(bookingA), CancellationToken.None);
var b = await handler.Handle(new IssueInvoiceCommand(bookingB), CancellationToken.None);
Assert.True(a.IsSuccess);
Assert.Equal("INV-0000000001", a.Result.InvoiceNumber);
Assert.Equal("1500000", a.Result.PlatformCommissionIrr);
Assert.Equal("150000", a.Result.VatIrr); // 10% of the commission line, integer-only
Assert.Equal(MoadianStatus.Pending, a.Result.MoadianStatus);
Assert.Null(a.Result.MoadianReferenceNumber);
// Gap-free next number, and VAT on that booking's own commission.
Assert.Equal("INV-0000000002", b.Result.InvoiceNumber);
Assert.Equal("300000", b.Result.VatIrr);
}
[Fact]
public async Task Reissue_is_idempotent_and_returns_the_same_invoice()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking();
var handler = Handler(host);
var first = await handler.Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None);
var second = await handler.Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None);
Assert.Equal(first.Result.Id, second.Result.Id);
Assert.Equal(first.Result.InvoiceNumber, second.Result.InvoiceNumber);
}
[Fact]
public async Task Zero_vat_rate_yields_zero_vat()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var result = await Handler(host, vatRate: 0m).Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("0", result.Result.VatIrr);
}
}
@@ -0,0 +1,138 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
public class RefundHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static CreateRefundCommandHandler Handler(RefundsTestHost host, bool nursePaid)
=> new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>());
private static CreateRefundCommand FullRefund(long bookingId)
=> new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null);
[Fact]
public async Task PrePayout_full_refund_posts_balanced_reversal_and_clearing()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var result = await Handler(host, nursePaid: false).Handle(FullRefund(bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(RefundStatus.Succeeded, result.Result.Status);
Assert.Equal("10000000", result.Result.Amount);
Assert.Equal("1500000", result.Result.PlatformFeeRefundedIrr);
Assert.Equal("8500000", result.Result.NursePayoutRefundedIrr);
Assert.Null(result.Result.ClawbackId);
var legs = host.LedgerFor(bookingId);
Assert.Equal(5, legs.Count); // reversal (3) + clearing (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
Assert.Equal(1_500_000, Leg(legs, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit));
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Debit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.RefundPayable, LedgerDirection.Credit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.RefundPayable, LedgerDirection.Debit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit));
// No clawback pre-payout.
Assert.Empty(host.Db.Set<NurseClawback>().AsNoTracking().ToList());
}
[Fact]
public async Task Partial_refund_decomposes_legs_and_second_over_refund_is_rejected()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var handler = Handler(host, nursePaid: false);
var half = new CreateRefundCommand(bookingId, null, RefundPercentage: 0.5m, null, null, "shortened_visit", null, null, null);
var first = await handler.Handle(half, CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.Equal("5000000", first.Result.Amount);
Assert.Equal("750000", first.Result.PlatformFeeRefundedIrr);
Assert.Equal("4250000", first.Result.NursePayoutRefundedIrr);
// A second 60% refund would push the total to 11M > 10M captured → rejected, no new ledger.
var ledgerBefore = host.LedgerFor(bookingId).Count;
var tooMuch = new CreateRefundCommand(bookingId, null, RefundPercentage: 0.6m, null, null, "again", null, null, null);
var second = await handler.Handle(tooMuch, CancellationToken.None);
Assert.False(second.IsSuccess);
Assert.True(second.IsConflict);
Assert.Equal(ledgerBefore, host.LedgerFor(bookingId).Count);
}
[Fact]
public async Task PostPayout_refund_opens_clawback_and_posts_receivable_leg()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var result = await Handler(host, nursePaid: true).Handle(FullRefund(bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Result.ClawbackId);
var legs = host.LedgerFor(bookingId);
// The payout leg debits the receivable (not nurse_payable); the platform fee leg is unchanged.
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Debit));
Assert.Equal(0, legs.Count(l => l.AccountType == LedgerAccountType.NursePayable));
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
var clawback = Assert.Single(host.Db.Set<NurseClawback>().AsNoTracking().ToList());
Assert.Equal(ClawbackStatus.Pending, clawback.Status);
Assert.Equal(8_500_000, clawback.AmountIrr);
Assert.Equal(host.NurseId, clawback.NurseId);
}
[Fact]
public async Task Card_and_bnpl_post_the_same_reversal_legs()
{
using var cardHost = new RefundsTestHost();
var (cardBooking, _) = cardHost.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Standard);
var cardResult = await Handler(cardHost, nursePaid: false).Handle(FullRefund(cardBooking), CancellationToken.None);
using var bnplHost = new RefundsTestHost();
var (bnplBooking, _) = bnplHost.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Bnpl);
var bnplResult = await Handler(bnplHost, nursePaid: false).Handle(FullRefund(bnplBooking), CancellationToken.None);
Assert.Equal(RefundChannel.PspCard, cardResult.Result.RefundChannel);
Assert.Equal(RefundChannel.BnplRevert, bnplResult.Result.RefundChannel);
// The card refund is immediate (succeeded, no ETA); the BNPL revert waits in processing with an ETA.
Assert.Equal(RefundStatus.Succeeded, cardResult.Result.Status);
Assert.Null(cardResult.Result.ExpectedCustomerRefundEta);
Assert.Equal(RefundStatus.Processing, bnplResult.Result.Status);
Assert.NotNull(bnplResult.Result.ExpectedCustomerRefundEta);
// Reversal legs (account × direction × amount) are identical across channels.
var cardReversal = Reversal(cardHost.LedgerFor(cardBooking));
var bnplReversal = Reversal(bnplHost.LedgerFor(bnplBooking));
Assert.Equal(cardReversal, bnplReversal);
}
private static long Leg(IReadOnlyList<LedgerEntry> legs, string account, string direction)
=> legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr);
private static IReadOnlyList<(string, string, long)> Reversal(IReadOnlyList<LedgerEntry> legs)
=> legs.Where(l => l.SourceRefType == LedgerSourceRefType.Refund && l.AccountType != LedgerAccountType.EscrowHeld)
.Where(l => !(l.AccountType == LedgerAccountType.RefundPayable && l.Direction == LedgerDirection.Debit))
.Select(l => (l.AccountType, l.Direction, l.AmountIrr))
.OrderBy(t => t.Item1).ThenBy(t => t.Item2).ToList();
}
@@ -0,0 +1,225 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Refunds;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (schema, CHECK, filtered indexes, query filters,
/// the invoice-number counter seed) for the b11 refund/clawback/invoice engine. Seeds one bookable nurse + one
/// customer, and can create a confirmed booking with a captured (succeeded) card/BNPL transaction so a test can
/// drive the real <see cref="UnitOfWork"/> against the real handlers with substituted seams.
/// </summary>
public sealed class RefundsTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
private readonly long _cityId;
private readonly long _categoryId;
private readonly long _patientId;
private readonly long _addressId;
public RefundsTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
_cityId = city.Id;
_categoryId = category.Id;
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
_patientId = patient.Id;
_addressId = address.Id;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
}
/// <summary>Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy
/// <c>gross = commission + payout</c>. <paramref name="disputeWindowEndsAt"/> sets the paid-proxy window.</summary>
public (long BookingId, long TransactionId) SeedCapturedBooking(
long gross = 10_000_000, long commission = 1_500_000, string gatewayType = PaymentGatewayType.Standard,
DateTime? disputeWindowEndsAt = null)
{
var variant = new NurseServiceVariant
{
NurseId = NurseId, ServiceCategoryId = _categoryId, Price = gross, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
var request = new BookingRequest
{
CustomerId = CustomerId, NurseId = NurseId, PatientId = _patientId, VariantId = variant.Id,
CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Db.SaveChanges();
var booking = new BookingEntity
{
BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = NurseId, PatientId = _patientId,
VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
GrossPriceIrr = gross, BalinyaarCommissionIrr = commission, PlatformFeeRate = 0.15m,
NursePayoutAmount = gross - commission, SessionCount = 1,
ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
};
booking.TransitionTo(BookingStatus.Confirmed, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc));
if (disputeWindowEndsAt is { } d)
booking.SetDisputeWindow(d);
Db.Set<BookingEntity>().Add(booking);
Db.SaveChanges();
var gateway = new PaymentGateway
{
ProviderCode = gatewayType == PaymentGatewayType.Bnpl ? "snapppay" : "zarinpal",
Type = gatewayType, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0
};
Db.Set<PaymentGateway>().Add(gateway);
Db.SaveChanges();
var txn = new PaymentTransaction
{
BookingRequestId = request.Id, CustomerId = CustomerId, GatewayId = gateway.Id,
Amount = gross, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}"
};
txn.MarkSucceeded(booking.Id, "ok", null);
Db.Set<PaymentTransaction>().Add(txn);
Db.SaveChanges();
return (booking.Id, txn.Id);
}
public ICurrentUser AsAdmin(int userId = 9999)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(userId);
u.Roles.Returns(new[] { RoleNames.Admin });
return u;
}
public IDateTimeProvider Clock(DateTimeOffset now)
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(now);
return c;
}
public IPlatformConfig Config(decimal vatRate = 0.10m, bool ticketRequired = false, int bnplEtaDays = 10)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("vat_rate", Arg.Any<CancellationToken>()).Returns(vatRate);
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(0.15m);
cfg.GetConfig<bool>("refund_ticket_required", Arg.Any<CancellationToken>()).Returns(ticketRequired);
cfg.GetConfig<int>("bnpl_refund_eta_business_days", Arg.Any<CancellationToken>()).Returns(bnplEtaDays);
return cfg;
}
public INursePayoutStatus PayoutStatus(bool paid)
{
var s = Substitute.For<INursePayoutStatus>();
s.IsNursePaidForBookingAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(paid);
return s;
}
public IPaymentProvider Card()
{
var p = Substitute.For<IPaymentProvider>();
p.RefundAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"card-refund-{ci.ArgAt<string>(0)}"));
return p;
}
public IBnplProvider Bnpl()
{
var p = Substitute.For<IBnplProvider>();
p.RevertAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new BnplRevertResult(PaymentProviderStatus.Succeeded, "bnpl-revert", null));
p.UpdateAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new BnplRevertResult(PaymentProviderStatus.Succeeded, "bnpl-update", null));
return p;
}
public IDistributedLock Lock() => new NoOpLock();
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
private sealed class NoOpLock : IDistributedLock
{
public ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IAsyncDisposable>(new Handle());
private sealed class Handle : IAsyncDisposable
{
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
}