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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user