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;
}