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