frontend phase 5 & backend phase 12

This commit is contained in:
hamid
2026-07-09 03:05:14 +03:30
parent 465f75c29e
commit dc64472631
98 changed files with 11847 additions and 136 deletions
@@ -1,27 +1,78 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
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).
/// A deterministic, network-free mock <see cref="IBnplProvider"/> that drives the full SnappPay-superset verb
/// set — <c>eligible → token_issued → verified → settled → reverted/cancelled</c> — with no external call. One
/// instance stands in for every <c>provider_code</c> (all Iranian provider-financed BNPLs behave the same);
/// a real system maps each code to its concrete adapter (SnappPay OAuth/eligible/token/verify/settle/revert,
/// Digipay UPG ticket/verify/deliver/refund). The mock commission % is <see cref="BnplOptions.CommissionRate"/>,
/// so the settle returns <c>order commission</c> and the handler reads the commission from the response, never
/// hardcoded. A real adapter reads the actual deducted amount from each settlement.
/// </summary>
public sealed class MockBnplProvider(IOptions<SeamOptions> options) : IBnplProvider
{
private readonly BnplOptions _options = options.Value.Bnpl;
public ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default)
{
var status = string.Equals(customerMobile, _options.NotEligibleMobile, StringComparison.Ordinal)
? BnplEligibilityStatus.NotEligible
: orderAmountIrr > _options.CreditCeilingIrr
? BnplEligibilityStatus.CeilingExceeded
: BnplEligibilityStatus.Eligible;
return ValueTask.FromResult(new BnplEligibilityResult(
status, InstallmentCount: 4, CreditCeilingIrr: _options.CreditCeilingIrr,
PlanSummary: "4 interest-free installments, 0% interest, provider-financed."));
}
public ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null));
var token = $"mock-bnpl-token-{orderAmountIrr}-{idempotencyKey}";
return ValueTask.FromResult(new BnplTokenResult(
PaymentProviderStatus.Succeeded, token,
RedirectUrl: $"https://mock-bnpl.local/checkout/{token}",
ExternalTransactionId: $"mock-bnpl-order-{idempotencyKey}"));
}
public ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplVerifyResult(
PaymentProviderStatus.Succeeded, expectedOrderAmountIrr, $"mock-bnpl-verify-{externalPaymentToken}"));
public ValueTask<BnplSettleResult> SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
// The merchant discount is read from the (mock) settlement — commission %, never hardcoded in a handler.
var commission = (long)Math.Round(orderAmountIrr * _options.CommissionRate, MidpointRounding.AwayFromZero);
var settled = orderAmountIrr - commission;
// Settlement timing is contract-defined and not instant — the mock can model it null (deferred) or now.
DateTime? settledAt = _options.SettlementInstant ? DateTime.UtcNow : null;
return ValueTask.FromResult(new BnplSettleResult(
PaymentProviderStatus.Succeeded, settled, commission, settledAt, $"mock-bnpl-settle-{idempotencyKey}"));
}
public ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplStatusResult("settled"));
public ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> Reversal(externalPaymentToken, idempotencyKey);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
=> Reversal(providerOrderReference, idempotencyKey);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
=> Reversal(providerOrderReference, idempotencyKey);
private ValueTask<BnplRevertResult> Result(string providerOrderReference, string idempotencyKey)
private ValueTask<BnplRevertResult> Reversal(string providerOrderReference, string idempotencyKey)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null));
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Config-driven <see cref="IBnplProviderResolver"/> — maps every known <c>provider_code</c> to the deterministic
/// <see cref="MockBnplProvider"/> (all four Iranian provider-financed BNPLs share the mock behaviour). A real
/// system registers one concrete adapter per code (<c>SnappPayBnplProvider</c>, <c>DigipayBnplProvider</c>, …)
/// and this resolver returns the right one; swapping is a registration change, <b>never</b> an <c>if (mock)</c>
/// branch in a handler. An unknown code resolves to <c>null</c> so the handler rejects it cleanly.
/// </summary>
public sealed class MockBnplProviderResolver(MockBnplProvider provider) : IBnplProviderResolver
{
public IBnplProvider? Resolve(string providerCode)
=> BnplProviderCodes.IsKnown(providerCode) ? provider : null;
}
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="ICurrencyNormalizer"/> — Toman ↔ IRR at the provider boundary only. Toman is
/// multiplied by <see cref="CurrencyOptions.TomanToIrrMultiplier"/> (10) to get IRR (and divided back for
/// display); IRR passes through unchanged. A real adapter reads the multiplier from provider config; the
/// conversion never happens internally, only here.
/// </summary>
public sealed class MockCurrencyNormalizer(IOptions<SeamOptions> options) : ICurrencyNormalizer
{
private readonly CurrencyOptions _options = options.Value.Currency;
public long ToIrr(long amount, string currency)
=> string.Equals(currency, "TOMAN", StringComparison.OrdinalIgnoreCase)
? amount * _options.TomanToIrrMultiplier
: amount;
public long ToDisplayToman(long amountIrr)
=> _options.TomanToIrrMultiplier == 0 ? amountIrr : amountIrr / _options.TomanToIrrMultiplier;
}
@@ -18,6 +18,18 @@ public sealed class SeamOptions
public PaymentsOptions Payments { get; set; } = new();
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
public CurrencyOptions Currency { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>ICurrencyNormalizer</c> (b12). Toman → IRR multiplies by <see cref="TomanToIrrMultiplier"/>
/// (10); IRR passes through. The real adapter reads the multiplier from provider config. Conversion happens only
/// at the provider boundary, never internally.
/// </summary>
public sealed class CurrencyOptions
{
/// <summary>How many Rial one Toman is (10). A currency redenomination is a config change, not a code change.</summary>
public long TomanToIrrMultiplier { get; set; } = 10;
}
/// <summary>
@@ -32,18 +44,32 @@ public sealed class MoadianOptions
}
/// <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.
/// Tunes the deterministic mock <c>IBnplProvider</c> (b12, superset of the b11 revert-only stub). The mock
/// commission % drives the settle (net = order commission); the real adapter reads the actual deducted amount
/// from each settlement and ignores these knobs.
/// </summary>
public sealed class BnplOptions
{
/// <summary>When true, every revert/update fails so the refund-channel-refused path is testable.</summary>
/// <summary>When true, token/revert/update/cancel all fail so the provider-declined paths are 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>The mock provider merchant commission rate (fraction) the settle deducts. Read from the response
/// by the handler, never hardcoded there; the real adapter reads the true per-contract deducted amount.</summary>
public decimal CommissionRate { get; set; } = 0.10m;
/// <summary>When true the mock settle stamps <c>settled_at = now</c>; false models the non-instant
/// (deferred/T+13/weekly) settlement with a null <c>settled_at</c>.</summary>
public bool SettlementInstant { get; set; } = true;
/// <summary>The provider credit ceiling (IRR); an order above it returns <c>ceiling_exceeded</c>.</summary>
public long CreditCeilingIrr { get; set; } = 2_000_000_000;
/// <summary>A designated test mobile that returns <c>not_eligible</c> so the fall-back-to-card path is testable.</summary>
public string NotEligibleMobile { get; set; } = "09120000099";
}
/// <summary>
@@ -60,10 +60,18 @@ public static class ServiceCollectionExtension
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.
// default; config can force registered).
services.AddSingleton<IMoadianClient, MockMoadianClient>();
services.AddSingleton<IBnplProvider, MockBnplProvider>();
// BNPL seams (backend-phase-12). The deterministic MockBnplProvider drives the full eligible → settled →
// reverted state machine with no network; the resolver selects one impl per provider_code (config-driven,
// never an if(mock) in a handler); ICurrencyNormalizer does Toman↔IRR at the boundary only. A real
// SnappPay/Digipay adapter + real Redis normalizer swap in by a registration change only. IBnplProvider
// is still registered directly for the b11 refund path's bnpl_revert channel.
services.AddSingleton<MockBnplProvider>();
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
return services;
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BnplConfig;
/// <summary>
/// <c>bnpl_transactions</c> — one row per BNPL order, <b>1:1 with its <c>payment_transaction</c></b>. The
/// <c>UNIQUE(payment_transaction_id)</c> is the structural one-BNPL-row-per-order guard; the settle invariant
/// <c>settled = order commission</c> (with both net + commission set together) is a DB CHECK mirroring b9/b11.
/// The state-machine guard on <c>status</c> lives in the entity; there is no <c>bnpl_settlement_entries</c> child
/// table (tranched settlement is DEFERRED — adding it later is a purely additive migration).
/// </summary>
internal sealed class BnplTransactionConfig : IEntityTypeConfiguration<BnplTransaction>
{
public void Configure(EntityTypeBuilder<BnplTransaction> builder)
{
builder.ToTable("BnplTransactions", "payments", t => t.HasCheckConstraint(
"CK_BnplTransactions_SettleSplit",
"([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) " +
"OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] " +
"AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"));
builder.Property(t => t.ProviderCode).HasMaxLength(50).IsRequired();
builder.Property(t => t.MerchantOfRecord).HasMaxLength(40).IsRequired();
builder.Property(t => t.ExternalPaymentToken).HasMaxLength(200);
builder.Property(t => t.ExternalTransactionId).HasMaxLength(200);
builder.Property(t => t.EligibilityStatus).HasMaxLength(30);
builder.Property(t => t.Currency).HasMaxLength(5).IsRequired();
builder.Property(t => t.Status).HasMaxLength(30).IsRequired();
builder.Property(t => t.RevertTransactionId).HasMaxLength(200);
builder.Property(t => t.RefundChannel).HasMaxLength(20);
// Strict 1:1 — exactly one BNPL row per order. The structural guard, not just a handler pre-check.
builder.HasIndex(t => t.PaymentTransactionId).IsUnique();
// The callback dispatch resolves the order from the provider token in the payload.
builder.HasIndex(t => t.ExternalPaymentToken).HasFilter("[ExternalPaymentToken] IS NOT NULL");
builder.HasIndex(t => t.Status);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(t => t.PaymentTransactionId).IsRequired();
builder.HasQueryFilter(t => t.DeletedAt == null);
}
}
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BnplTransactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BnplTransactions",
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),
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
MerchantOfRecord = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
ExternalPaymentToken = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExternalTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
EligibilityStatus = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: true),
OrderAmountIrr = table.Column<long>(type: "bigint", nullable: false),
SettledAmountIrr = table.Column<long>(type: "bigint", nullable: true),
BnplCommissionIrr = table.Column<long>(type: "bigint", nullable: true),
Currency = table.Column<string>(type: "nvarchar(5)", maxLength: 5, nullable: false),
InstallmentCount = table.Column<byte>(type: "tinyint", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
SettledAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevertTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
RevertedAmountIrr = table.Column<long>(type: "bigint", nullable: true),
RevertedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ProviderCommissionReversedAmount = table.Column<long>(type: "bigint", nullable: true),
RefundChannel = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CallbackPayloadJson = table.Column<string>(type: "nvarchar(max)", 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_BnplTransactions", x => x.Id);
table.CheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)");
table.ForeignKey(
name: "FK_BnplTransactions_PaymentTransactions_PaymentTransactionId",
column: x => x.PaymentTransactionId,
principalSchema: "payments",
principalTable: "PaymentTransactions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_ExternalPaymentToken",
schema: "payments",
table: "BnplTransactions",
column: "ExternalPaymentToken",
filter: "[ExternalPaymentToken] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_PaymentTransactionId",
schema: "payments",
table: "BnplTransactions",
column: "PaymentTransactionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_Status",
schema: "payments",
table: "BnplTransactions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BnplTransactions",
schema: "payments");
}
}
}
@@ -98,6 +98,115 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BnplCommissionIrr")
.HasColumnType("bigint");
b.Property<string>("CallbackPayloadJson")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("nvarchar(5)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EligibilityStatus")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<string>("ExternalPaymentToken")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ExternalTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<byte>("InstallmentCount")
.HasColumnType("tinyint");
b.Property<string>("MerchantOfRecord")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("OrderAmountIrr")
.HasColumnType("bigint");
b.Property<long>("PaymentTransactionId")
.HasColumnType("bigint");
b.Property<string>("ProviderCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long?>("ProviderCommissionReversedAmount")
.HasColumnType("bigint");
b.Property<string>("RefundChannel")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RevertTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<long?>("RevertedAmountIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("RevertedAt")
.HasColumnType("datetime2");
b.Property<long?>("SettledAmountIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("SettledAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.HasKey("Id");
b.HasIndex("ExternalPaymentToken")
.HasFilter("[ExternalPaymentToken] IS NOT NULL");
b.HasIndex("PaymentTransactionId")
.IsUnique();
b.HasIndex("Status");
b.ToTable("BnplTransactions", "payments", t =>
{
t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Property<long>("Id")
@@ -4203,6 +4312,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b =>
{
b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null)
.WithMany()
.HasForeignKey("PaymentTransactionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
@@ -0,0 +1,111 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class BnplRepository : BaseAsyncRepository<BnplTransaction>, IBnplRepository
{
public BnplRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<BnplOrderContext?> GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken)
=> (from r in DbContext.Set<BookingRequest>().AsNoTracking()
where r.Id == bookingRequestId
join c in DbContext.Set<CustomerProfile>() on r.CustomerId equals c.Id
join u in DbContext.Set<User>() on c.UserId equals u.Id
select new BnplOrderContext(
r.Id,
r.CustomerId,
u.Id,
u.PhoneNumber,
r.Status,
r.PaymentDeadlineAt,
r.Variant.Price * (r.Variant.SessionCount != null ? r.Variant.SessionCount.Value : 1)))
.FirstOrDefaultAsync(cancellationToken);
public Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken)
=> base.AddAsync(transaction);
public Task<BnplTransaction?> GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.PaymentTransactionId == paymentTransactionId, cancellationToken);
public Task<BnplTransaction?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
public Task<BnplTransaction?> GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken)
// A correlated subquery keeps the root Table query tracking (a join to an AsNoTracking set would detach
// the returned entity, so a later mutation would never persist).
=> Table.FirstOrDefaultAsync(
t => DbContext.Set<PaymentTransaction>().Any(pt => pt.Id == t.PaymentTransactionId && pt.BookingRequestId == bookingRequestId),
cancellationToken);
public Task<BnplTransaction?> GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.ExternalPaymentToken == externalPaymentToken, cancellationToken);
public Task<bool> SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AsNoTracking().AnyAsync(
e => e.SourceRefType == LedgerSourceRefType.BnplTransaction && e.SourceRefId == bnplTransactionId,
cancellationToken);
public async Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken)
{
var row = await (from t in TableNoTracking
where t.Id == id
join pt in DbContext.Set<PaymentTransaction>() on t.PaymentTransactionId equals pt.Id
join c in DbContext.Set<CustomerProfile>() on pt.CustomerId equals c.Id
select new
{
c.UserId,
t.Id,
t.PaymentTransactionId,
pt.BookingId,
t.ProviderCode,
t.Status,
t.EligibilityStatus,
t.OrderAmountIrr,
t.SettledAmountIrr,
t.BnplCommissionIrr,
t.Currency,
t.InstallmentCount,
t.SettledAt,
t.RevertTransactionId,
t.RevertedAmountIrr,
t.RevertedAt,
t.ProviderCommissionReversedAmount,
t.RefundChannel,
t.CreatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
// The async customer cash-back ETA lives on the linked bnpl_revert refund (b11), surfaced for the UI.
DateOnly? eta = null;
if (row.BookingId is { } bookingId && row.Status == BnplStatus.Reverted)
eta = await DbContext.Set<Refund>().AsNoTracking()
.Where(refund => refund.BookingId == bookingId && refund.RefundChannel == RefundChannel.BnplRevert)
.OrderByDescending(refund => refund.Id)
.Select(refund => refund.ExpectedCustomerRefundEta)
.FirstOrDefaultAsync(cancellationToken);
var dto = new BnplOrderStatusDto(
row.Id, row.PaymentTransactionId, row.BookingId, row.ProviderCode, row.Status, row.EligibilityStatus,
row.OrderAmountIrr.ToString(), row.SettledAmountIrr?.ToString(), row.BnplCommissionIrr?.ToString(),
row.Currency, row.InstallmentCount, row.SettledAt,
row.RevertTransactionId, row.RevertedAmountIrr?.ToString(), row.RevertedAt,
row.ProviderCommissionReversedAmount?.ToString(), row.RefundChannel, eta, row.CreatedAt);
return new BnplOrderStatusProjection(row.UserId, dto);
}
}
@@ -25,6 +25,7 @@ public class UnitOfWork : IUnitOfWork
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -48,6 +49,7 @@ public class UnitOfWork : IUnitOfWork
PaymentRepository = new PaymentRepository(_db);
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db);
}
public Task CommitAsync()
@@ -132,6 +132,12 @@ internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRep
return new RefundStatusProjection(row.UserId, dto);
}
public Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.Id == refundId)
.Select(r => r.ExternalRevertReference)
.FirstOrDefaultAsync(cancellationToken);
// 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)
{