frontend phase 5 & backend phase 12
This commit is contained in:
+59
-8
@@ -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));
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
+24
@@ -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+1–3/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>
|
||||
|
||||
+11
-3
@@ -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;
|
||||
}
|
||||
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+4987
File diff suppressed because it is too large
Load Diff
+88
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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)
|
||||
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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()
|
||||
|
||||
+6
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user