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