backend phase 11

This commit is contained in:
hamid
2026-07-09 02:13:30 +03:30
parent 23605591eb
commit 465f75c29e
78 changed files with 9555 additions and 27 deletions
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A thin, deterministic mock <see cref="IBnplProvider"/> so b11's <c>bnpl_revert</c> refund path is exercised
/// before b12 merges — <b>b12 owns the real seam definition and its full adapter</b> (SnappPay/Tara). Revert
/// and update both succeed, echo a deterministic <c>external_revert_reference</c> derived from the order +
/// idempotency key, and report a nullable provider commission reversal (null by default — some providers keep
/// their fee on a refund; the amount is reconciled from the response, never hardcoded).
/// </summary>
public sealed class MockBnplProvider(IOptions<SeamOptions> options) : IBnplProvider
{
private readonly BnplOptions _options = options.Value.Bnpl;
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
private ValueTask<BnplRevertResult> Result(string providerOrderReference, string idempotencyKey)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null));
return ValueTask.FromResult(new BnplRevertResult(
PaymentProviderStatus.Succeeded,
ExternalRevertReference: $"mock-bnpl-revert-{providerOrderReference}-{idempotencyKey}",
ProviderCommissionReversedAmount: _options.ReverseProviderCommission ? 0 : null));
}
}
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Contracts.Invoices;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IMoadianClient"/> (b11) — no external call. By default a submission leaves the
/// invoice at <c>moadian_status = pending</c> with no reference (the real reconciliation later flips it to
/// <c>registered</c>). Set <see cref="MoadianOptions.ForceRegistered"/> to have it return <c>registered</c> with
/// a deterministic fake 22-digit reference so the <c>registered</c>/reconciliation path is testable. The real
/// سامانه مودیان adapter (enrollment, the معاملات/invoice submission API, the 22-digit reference) swaps only this
/// registration.
/// </summary>
public sealed class MockMoadianClient(IOptions<SeamOptions> options) : IMoadianClient
{
private readonly MoadianOptions _options = options.Value.Moadian;
public ValueTask<MoadianSubmissionResult> SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default)
{
if (!_options.ForceRegistered)
return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Pending, null));
// A deterministic 22-digit reference derived from the booking id (right-aligned, zero-padded).
var reference = submission.BookingId.ToString().PadLeft(22, '0');
return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Registered, reference));
}
}
@@ -24,6 +24,6 @@ public sealed class MockPaymentProvider : IPaymentProvider
public ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}"));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}-{idempotencyKey}"));
}
@@ -16,6 +16,34 @@ public sealed class SeamOptions
public IdentityKycOptions IdentityKyc { get; set; } = new();
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
public PaymentsOptions Payments { get; set; } = new();
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IMoadianClient</c> (b11 e-invoicing). By default a submission stays <c>pending</c> with no
/// reference. Set <see cref="ForceRegistered"/> to make it return <c>registered</c> with a fake 22-digit ref so
/// the reconciliation/registered path is testable. The real سامانه مودیان adapter ignores these.
/// </summary>
public sealed class MoadianOptions
{
/// <summary>When true, a submission returns <c>registered</c> + a deterministic fake 22-digit reference.</summary>
public bool ForceRegistered { get; set; }
}
/// <summary>
/// Tunes the thin local mock <c>IBnplProvider</c> b11 registers until b12 ships the real seam. By default a
/// revert/update succeeds and the provider keeps its commission (null reversal). The real b12 adapter ignores
/// these.
/// </summary>
public sealed class BnplOptions
{
/// <summary>When true, every revert/update fails so the refund-channel-refused path is testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>When true, the mock reports the provider returned its commission (a non-null, zero reversal
/// placeholder) so the <c>provider_commission_reversed_amount</c> reconciliation is exercised.</summary>
public bool ReverseProviderCommission { get; set; }
}
/// <summary>
@@ -1,4 +1,5 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments;
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration;
@@ -58,6 +59,12 @@ public static class ServiceCollectionExtension
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered). IBnplProvider is a thin local stub so the bnpl_revert refund
// path runs before b12 merges — b12 owns the real seam. Both swap in by a registration change only.
services.AddSingleton<IMoadianClient, MockMoadianClient>();
services.AddSingleton<IBnplProvider, MockBnplProvider>();
return services;
}
}
@@ -46,6 +46,9 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
];
return rows
@@ -0,0 +1,34 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential
/// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the
/// commission line only. <c>partner_center_id</c> is a nullable column with <b>no FK</b> — <c>partner_centers</c>
/// is a forward-dep on b15.
/// </summary>
internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> builder)
{
builder.ToTable("Invoices", "payments");
builder.Property(i => i.InvoiceNumber).HasMaxLength(40).IsRequired();
builder.Property(i => i.IssuingEntityType).HasMaxLength(20).IsRequired();
builder.Property(i => i.MoadianReferenceNumber).HasMaxLength(40);
builder.Property(i => i.MoadianStatus).HasMaxLength(20);
builder.Property(i => i.PdfStorageKey).HasMaxLength(512);
builder.Property(i => i.VatRate).HasPrecision(5, 4);
builder.HasIndex(i => i.InvoiceNumber).IsUnique();
builder.HasIndex(i => i.BookingId).IsUnique();
builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired();
builder.HasQueryFilter(i => i.DeletedAt == null);
}
}
@@ -0,0 +1,23 @@
using Baya.Domain.Entities.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// The single-row counter behind the sequential <c>invoice_number</c>. Seeded with one row (id 1, next = 1) so
/// <c>EnsureCreated</c> (tests) and the migration both start the sequence. The id is fixed (never generated) —
/// there is exactly one counter. Portable across SQL Server and SQLite (no provider-specific DB sequence).
/// </summary>
internal sealed class InvoiceNumberSequenceConfig : IEntityTypeConfiguration<InvoiceNumberSequence>
{
public void Configure(EntityTypeBuilder<InvoiceNumberSequence> builder)
{
builder.ToTable("InvoiceNumberSequences", "payments");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).ValueGeneratedNever();
builder.HasData(new InvoiceNumberSequence { Id = 1, NextValue = 1 });
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable columns + indexes now
/// with <b>no FK</b> — <c>nurse_payouts</c> is a forward-dep on b13, which sets the values and wires the FKs.
/// </summary>
internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback>
{
public void Configure(EntityTypeBuilder<NurseClawback> builder)
{
builder.ToTable("NurseClawbacks", "payments");
builder.Property(c => c.Status).HasMaxLength(30).IsRequired();
builder.Property(c => c.ResolutionNotes).HasMaxLength(500);
builder.HasIndex(c => c.NurseId);
builder.HasIndex(c => c.BookingId);
builder.HasIndex(c => c.RefundId).IsUnique();
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.OriginalPayoutId);
builder.HasIndex(c => c.RecoveredInPayoutId);
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired();
builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,50 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation
/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a
/// single-row constraint). <c>ticket_id</c> is a nullable column + index now with <b>no FK</b> — the
/// <c>tickets</c> table is a forward-dep on b15, which wires the real FK target.
/// </summary>
internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
{
public void Configure(EntityTypeBuilder<Refund> builder)
{
builder.ToTable("Refunds", "payments", t => t.HasCheckConstraint(
"CK_Refunds_LegSplit",
"[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] " +
"AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"));
builder.Property(r => r.RefundChannel).HasMaxLength(20).IsRequired();
builder.Property(r => r.Status).HasMaxLength(20).IsRequired();
builder.Property(r => r.ReasonCategory).HasMaxLength(50);
builder.Property(r => r.ReasonNotes).HasMaxLength(1000);
builder.Property(r => r.AdminNotes).HasMaxLength(1000);
builder.Property(r => r.RejectedReason).HasMaxLength(500);
builder.Property(r => r.GatewayRefundReference).HasMaxLength(200);
builder.Property(r => r.ExternalRevertReference).HasMaxLength(200);
builder.Property(r => r.CancellationPolicyCode).HasMaxLength(50);
builder.Property(r => r.RefundPercentage).HasPrecision(6, 4);
builder.Property(r => r.RefundPercentageApplied).HasPrecision(5, 2);
builder.HasIndex(r => r.PaymentTransactionId);
builder.HasIndex(r => r.BookingId);
builder.HasIndex(r => r.RequestedByCustomerId);
builder.HasIndex(r => r.Status);
// Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist).
builder.HasIndex(r => r.TicketId);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired();
builder.HasQueryFilter(r => r.DeletedAt == null);
}
}
@@ -0,0 +1,313 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefundsClawbacksInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InvoiceNumberSequences",
schema: "payments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
NextValue = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceNumberSequences", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Invoices",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
InvoiceNumber = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
IssuingEntityType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
GrossIrr = table.Column<long>(type: "bigint", nullable: false),
PlatformCommissionIrr = table.Column<long>(type: "bigint", nullable: false),
BnplCommissionIrr = table.Column<long>(type: "bigint", nullable: true),
VatRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false),
VatIrr = table.Column<long>(type: "bigint", nullable: false),
MoadianReferenceNumber = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
MoadianStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
PdfStorageKey = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
IssuedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Invoices", x => x.Id);
table.ForeignKey(
name: "FK_Invoices_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Refunds",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PaymentTransactionId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
RequestedByCustomerId = table.Column<long>(type: "bigint", nullable: false),
TicketId = table.Column<long>(type: "bigint", nullable: true),
Amount = table.Column<long>(type: "bigint", nullable: false),
PlatformFeeRefundedIrr = table.Column<long>(type: "bigint", nullable: false),
NursePayoutRefundedIrr = table.Column<long>(type: "bigint", nullable: false),
RefundPercentage = table.Column<decimal>(type: "decimal(6,4)", precision: 6, scale: 4, nullable: false),
RefundChannel = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ReasonCategory = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
ReasonNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ApprovedByAdminId = table.Column<int>(type: "int", nullable: true),
RejectedReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
AdminNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
GatewayRefundReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExternalRevertReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExpectedCustomerRefundEta = table.Column<DateOnly>(type: "date", nullable: true),
CancellationPolicyCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RefundPercentageApplied = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Refunds", x => x.Id);
table.CheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0");
table.ForeignKey(
name: "FK_Refunds_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Refunds_CustomerProfiles_RequestedByCustomerId",
column: x => x.RequestedByCustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Refunds_PaymentTransactions_PaymentTransactionId",
column: x => x.PaymentTransactionId,
principalSchema: "payments",
principalTable: "PaymentTransactions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseClawbacks",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
RefundId = table.Column<long>(type: "bigint", nullable: false),
OriginalPayoutId = table.Column<long>(type: "bigint", nullable: true),
AmountIrr = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
RecoveredInPayoutId = table.Column<long>(type: "bigint", nullable: true),
ResolvedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ResolutionNotes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseClawbacks", x => x.Id);
table.ForeignKey(
name: "FK_NurseClawbacks_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseClawbacks_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseClawbacks_Refunds_RefundId",
column: x => x.RefundId,
principalSchema: "payments",
principalTable: "Refunds",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "payments",
table: "InvoiceNumberSequences",
columns: new[] { "Id", "NextValue" },
values: new object[] { 1, 1L });
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" },
{ 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Business days shown as the customer BNPL refund ETA (b11).", "bnpl_refund_eta_business_days", null, null, "10" },
{ 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", "refund_assume_nurse_paid", null, null, "false" }
});
migrationBuilder.CreateIndex(
name: "IX_Invoices_BookingId",
schema: "payments",
table: "Invoices",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Invoices_InvoiceNumber",
schema: "payments",
table: "Invoices",
column: "InvoiceNumber",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_BookingId",
schema: "payments",
table: "NurseClawbacks",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_NurseId",
schema: "payments",
table: "NurseClawbacks",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "OriginalPayoutId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "RecoveredInPayoutId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_RefundId",
schema: "payments",
table: "NurseClawbacks",
column: "RefundId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_Status",
schema: "payments",
table: "NurseClawbacks",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Refunds_BookingId",
schema: "payments",
table: "Refunds",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_PaymentTransactionId",
schema: "payments",
table: "Refunds",
column: "PaymentTransactionId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_RequestedByCustomerId",
schema: "payments",
table: "Refunds",
column: "RequestedByCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_Status",
schema: "payments",
table: "Refunds",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Refunds_TicketId",
schema: "payments",
table: "Refunds",
column: "TicketId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InvoiceNumberSequences",
schema: "payments");
migrationBuilder.DropTable(
name: "Invoices",
schema: "payments");
migrationBuilder.DropTable(
name: "NurseClawbacks",
schema: "payments");
migrationBuilder.DropTable(
name: "Refunds",
schema: "payments");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 19L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 20L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 21L);
}
}
}
@@ -1155,6 +1155,33 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).",
Key = "no_show_scan_cadence_hours",
Value = "1"
},
new
{
Id = 19L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.",
Key = "refund_ticket_required",
Value = "false"
},
new
{
Id = 20L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Business days shown as the customer BNPL refund ETA (b11).",
Key = "bnpl_refund_eta_business_days",
Value = "10"
},
new
{
Id = 21L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.",
Key = "refund_assume_nurse_paid",
Value = "false"
});
});
@@ -2630,6 +2657,107 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Patients", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BnplCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("GrossIrr")
.HasColumnType("bigint");
b.Property<string>("InvoiceNumber")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTime>("IssuedAt")
.HasColumnType("datetime2");
b.Property<string>("IssuingEntityType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("MoadianReferenceNumber")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("MoadianStatus")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<string>("PdfStorageKey")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<long>("PlatformCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("VatIrr")
.HasColumnType("bigint");
b.Property<decimal>("VatRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.HasIndex("InvoiceNumber")
.IsUnique();
b.ToTable("Invoices", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<long>("NextValue")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("InvoiceNumberSequences", "payments");
b.HasData(
new
{
Id = 1,
NextValue = 1L
});
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
@@ -2949,6 +3077,194 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("PaymentWebhookEvents", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("AmountIrr")
.HasColumnType("bigint");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long?>("OriginalPayoutId")
.HasColumnType("bigint");
b.Property<long?>("RecoveredInPayoutId")
.HasColumnType("bigint");
b.Property<long>("RefundId")
.HasColumnType("bigint");
b.Property<string>("ResolutionNotes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("NurseId");
b.HasIndex("OriginalPayoutId");
b.HasIndex("RecoveredInPayoutId");
b.HasIndex("RefundId")
.IsUnique();
b.HasIndex("Status");
b.ToTable("NurseClawbacks", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AdminNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<int?>("ApprovedByAdminId")
.HasColumnType("int");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<string>("CancellationPolicyCode")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateOnly?>("ExpectedCustomerRefundEta")
.HasColumnType("date");
b.Property<string>("ExternalRevertReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("GatewayRefundReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NursePayoutRefundedIrr")
.HasColumnType("bigint");
b.Property<long>("PaymentTransactionId")
.HasColumnType("bigint");
b.Property<long>("PlatformFeeRefundedIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("ReasonCategory")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ReasonNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RefundChannel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal>("RefundPercentage")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<decimal?>("RefundPercentageApplied")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<string>("RejectedReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<long>("RequestedByCustomerId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long?>("TicketId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("PaymentTransactionId");
b.HasIndex("RequestedByCustomerId");
b.HasIndex("Status");
b.HasIndex("TicketId");
b.ToTable("Refunds", "payments", t =>
{
t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.Property<long>("Id")
@@ -4186,6 +4502,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("Customer");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -4231,6 +4556,48 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany()
.HasForeignKey("RefundId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null)
.WithMany()
.HasForeignKey("PaymentTransactionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
.WithMany()
.HasForeignKey("RequestedByCustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
@@ -23,6 +23,8 @@ public class UnitOfWork : IUnitOfWork
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -44,6 +46,8 @@ public class UnitOfWork : IUnitOfWork
BookingRepository = new BookingRepository(_db);
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
PaymentRepository = new PaymentRepository(_db);
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,67 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Invoices;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class InvoiceRepository : BaseAsyncRepository<Invoice>, IInvoiceRepository
{
public InvoiceRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<InvoiceBookingAmounts?> GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken)
=> (from b in DbContext.Set<Booking>().AsNoTracking()
where b.Id == bookingId
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new InvoiceBookingAmounts(
b.Id,
c.UserId,
b.GrossPriceIrr,
b.BalinyaarCommissionIrr,
(long?)null))
.FirstOrDefaultAsync(cancellationToken);
public Task<Invoice?> GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(i => i.BookingId == bookingId, cancellationToken);
public async Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken)
{
var row = await (from i in TableNoTracking
where i.BookingId == bookingId
join b in DbContext.Set<Booking>() on i.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new { CustomerUserId = c.UserId, Invoice = i })
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
// The PDF URL is resolved by the handler through IObjectStorage — the repo carries the raw key.
var dto = new InvoiceDto(
row.Invoice.Id, row.Invoice.BookingId, row.Invoice.InvoiceNumber, row.Invoice.IssuingEntityType,
row.Invoice.GrossIrr.ToString(), row.Invoice.PlatformCommissionIrr.ToString(),
row.Invoice.BnplCommissionIrr?.ToString(), row.Invoice.VatRate, row.Invoice.VatIrr.ToString(),
row.Invoice.MoadianReferenceNumber, row.Invoice.MoadianStatus, PdfUrl: null, row.Invoice.IssuedAt);
return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto);
}
public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
=> base.AddAsync(invoice);
public async Task<long> ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken)
{
var counter = await DbContext.Set<InvoiceNumberSequence>().FirstOrDefaultAsync(s => s.Id == 1, cancellationToken)
?? throw new InvalidOperationException("The invoice-number counter row is missing.");
var reserved = counter.NextValue;
counter.NextValue = reserved + 1;
return reserved;
}
}
@@ -0,0 +1,144 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRepository
{
public RefundRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<RefundMoneyContext?> GetRefundContextAsync(long bookingId, CancellationToken cancellationToken)
=> (from t in DbContext.Set<PaymentTransaction>().AsNoTracking()
where t.BookingId == bookingId && t.Status == PaymentTransactionStatus.Succeeded
join b in DbContext.Set<Booking>() on t.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
join g in DbContext.Set<PaymentGateway>() on t.GatewayId equals g.Id
select new RefundMoneyContext(
b.Id,
b.CustomerId,
c.UserId,
b.NurseId,
b.GrossPriceIrr,
b.BalinyaarCommissionIrr,
b.NursePayoutAmount,
b.CancellationPolicyCode,
b.CancellationRefundPercentage,
b.RefundableAmountIrr,
t.Id,
t.GatewayReferenceCode,
t.Amount,
g.Type))
.FirstOrDefaultAsync(cancellationToken);
public async Task<long> GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> await TableNoTracking
.Where(r => r.PaymentTransactionId == paymentTransactionId
&& r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected)
.SumAsync(r => (long?)r.Amount, cancellationToken) ?? 0;
public Task AddRefundAsync(Refund refund, CancellationToken cancellationToken)
=> base.AddAsync(refund);
public Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().AddAsync(clawback, cancellationToken).AsTask();
public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking;
if (bookingId is { } bid)
query = query.Where(r => r.BookingId == bid);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(r => r.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(r => new
{
r.Id,
r.BookingId,
r.PaymentTransactionId,
r.Amount,
r.PlatformFeeRefundedIrr,
r.NursePayoutRefundedIrr,
r.RefundChannel,
r.Status,
r.RefundPercentage,
r.ReasonCategory,
r.CancellationPolicyCode,
r.RefundPercentageApplied,
r.ExpectedCustomerRefundEta,
r.GatewayRefundReference,
r.ExternalRevertReference,
r.ProcessedAt,
r.CreatedAt
})
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new RefundListItemDto(
r.Id, r.BookingId, r.PaymentTransactionId,
r.Amount.ToString(), r.PlatformFeeRefundedIrr.ToString(), r.NursePayoutRefundedIrr.ToString(),
r.RefundChannel, r.Status, r.RefundPercentage, r.ReasonCategory,
r.CancellationPolicyCode, r.RefundPercentageApplied, r.ExpectedCustomerRefundEta,
r.GatewayRefundReference, r.ExternalRevertReference, r.ProcessedAt, r.CreatedAt))
.ToList();
return new PagedResult<RefundListItemDto>(items, total, page, pageSize);
}
public async Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken)
{
var row = await (from r in TableNoTracking
where r.Id == id
join b in DbContext.Set<Booking>() on r.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new
{
c.UserId,
r.Id,
r.BookingId,
r.Status,
r.RefundChannel,
r.Amount,
r.ExpectedCustomerRefundEta,
r.GatewayRefundReference,
r.ExternalRevertReference
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
var reference = Mask(row.GatewayRefundReference ?? row.ExternalRevertReference);
var dto = new RefundStatusDto(row.Id, row.BookingId, row.Status, row.RefundChannel, row.Amount.ToString(),
row.ExpectedCustomerRefundEta, reference);
return new RefundStatusProjection(row.UserId, dto);
}
// Show only the last 4 characters of an external reference to the customer — never the full PSP/BNPL id.
private static string? Mask(string? reference)
{
if (string.IsNullOrEmpty(reference))
return reference;
return reference.Length <= 4
? new string('•', reference.Length)
: $"{new string('•', reference.Length - 4)}{reference[^4..]}";
}
}
@@ -4,6 +4,7 @@ using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
@@ -16,6 +17,7 @@ using Baya.Infrastructure.Persistence.Services.Booking;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Payments;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
using Microsoft.AspNetCore.Builder;
@@ -52,6 +54,11 @@ public static class ServiceCollectionExtensions
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real
// impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps
// this registration for the authoritative payout-link lookup.
services.AddScoped<INursePayoutStatus, NursePayoutStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
@@ -0,0 +1,35 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The interim implementation of <see cref="INursePayoutStatus"/> until b13 ships <c>nurse_payouts</c> /
/// <c>nurse_payout_booking_links</c>. It derives "already paid?" from the booking's dispute-window close — the
/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path
/// is the fallback. A <c>refund_assume_nurse_paid</c> config switch forces the paid answer for ops/testing. b13
/// swaps this registration for the authoritative payout-link lookup.
/// </summary>
internal sealed class NursePayoutStatusService(
ApplicationDbContext dbContext,
IDateTimeProvider dateTimeProvider,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
var now = dateTimeProvider.UtcNow.UtcDateTime;
var windowEnd = await dbContext.Set<BookingEntity>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => b.DisputeWindowEndsAt)
.FirstOrDefaultAsync(cancellationToken);
return windowEnd is { } endsAt && endsAt <= now;
}
}