Files
baya-monorepo/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs
T

306 lines
16 KiB
C#

using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Reviews;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.CrossCutting.Seams.Real;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension
{
/// <summary>
/// Registers the cross-cutting + vendor-rail seams. Each rail is <b>config-selected</b> (refinement-phase-8):
/// the deterministic mock is the default, and setting the rail's <c>Seams:*:Provider</c> to a real provider
/// token swaps in the real HTTP adapter behind the same Application contract — <b>callers never change</b>. An
/// unconfigured/typo'd provider falls closed to the mock. This makes a partial rollout the normal case (real SMS
/// + real geocoder while payments stay mocked in a pre-launch environment). Real adapters read credentials from
/// <c>Seams:*</c> (appsettings/environment) and get an <see cref="System.Net.Http.HttpClient"/> from the
/// <c>IHttpClientFactory</c>. (The real in-app <c>INotificationDispatcher</c> needs the database, so it is
/// registered in the Persistence layer.)
/// </summary>
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SeamOptions>(configuration.GetSection(SeamOptions.SectionName));
var seams = configuration.GetSection(SeamOptions.SectionName).Get<SeamOptions>() ?? new SeamOptions();
services.AddHttpClient();
services.AddMemoryCache();
services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>();
services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>();
services.AddSingleton<ICacheService, MemoryCacheService>();
RegisterObjectStorage(services, seams);
RegisterSms(services, seams);
RegisterTrustRails(services, seams);
RegisterGeocoder(services, seams);
RegisterPaymentRails(services, seams);
RegisterBnpl(services, seams);
RegisterPayoutRail(services, seams);
RegisterMoadian(services, seams);
// Payment-capture trigger (backend-phase-9). refinement-phase-8 (6.4): the real card capture (b10) supersedes
// it, so production registers the fail-closed DisabledPaymentCaptureSimulator (the dev-only bookings/convert
// endpoint fabricates nothing in a deployed env); Development/Testing re-register the real MockPaymentCaptureSimulator
// over this via AddDevelopmentPaymentCapture (last registration wins).
services.AddSingleton<IPaymentCaptureSimulator, DisabledPaymentCaptureSimulator>();
// Non-vendor mocks that stay as-is (real behaviour is out of this phase's scope / manual is the intended MVP).
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>(); // 5.6 manual = intended MVP
services.AddSingleton<IReviewModerationService, MockReviewModerationService>();
services.AddSingleton<ILicenseVerificationService, MockLicenseVerificationService>(); // 5.6 manual = intended MVP
return services;
}
// ---- object storage (5.5) --------------------------------------------------------------------------------
private static void RegisterObjectStorage(IServiceCollection services, SeamOptions seams)
{
if (Is(seams.ObjectStorage.Provider, SeamProviders.S3))
{
services.AddHttpClient(HttpClients.ObjectStorage);
services.AddSingleton<IObjectStorage>(sp => new S3ObjectStorage(
Client(sp, HttpClients.ObjectStorage),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>()));
}
else
{
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
}
}
// ---- SMS (5.1, launch-critical) --------------------------------------------------------------------------
private static void RegisterSms(IServiceCollection services, SeamOptions seams)
{
var provider = seams.Sms.Provider;
if (Is(provider, SeamProviders.Kavenegar))
{
services.AddHttpClient(HttpClients.Sms, c => c.BaseAddress = new Uri(BaseOrDefault(seams.Sms.BaseUrl, "https://api.kavenegar.com/")));
services.AddSingleton<ISmsSender>(sp => new KavenegarSmsSender(
Client(sp, HttpClients.Sms),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<KavenegarSmsSender>>()));
}
else if (Is(provider, SeamProviders.Telegram))
{
// Development-only relay (telegram-otp-bot/) — a manual-testing convenience, not a gateway. The
// per-request timeout is generous because the relay's own hop to Telegram goes through a proxy.
var telegram = seams.Sms.Telegram;
services.AddHttpClient(HttpClients.Telegram, c =>
{
c.BaseAddress = new Uri(BaseOrDefault(telegram.BaseUrl, "http://127.0.0.1:5010").TrimEnd('/') + "/");
c.Timeout = TimeSpan.FromSeconds(telegram.TimeoutSeconds > 0 ? telegram.TimeoutSeconds : 10);
});
services.AddSingleton<ISmsSender>(sp => new TelegramSmsSender(
Client(sp, HttpClients.Telegram),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<TelegramSmsSender>>()));
}
else if (Is(provider, SeamProviders.SmsIr) || Is(provider, SeamProviders.Ghasedak))
{
throw new NotSupportedException(
$"SMS provider '{provider}' is not implemented — only 'kavenegar' has a real adapter (refinement-phase-8). " +
"Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'/'telegram'.");
}
else
{
// b2 log-only mock; the Development OTP-capture decorator is layered on in Program.cs (Development only,
// and only for the capture-safe providers — a real SMS gateway disables it so the OTP is never logged).
services.AddSingleton<ISmsSender, LoggingSmsSender>();
}
}
// ---- trust & identity (5.2, 5.3) — Finnotech-class KYC bridge --------------------------------------------
private static void RegisterTrustRails(IServiceCollection services, SeamOptions seams)
{
var anyFinnotech =
Is(seams.Shahkar.Provider, SeamProviders.Finnotech) ||
Is(seams.IdentityKyc.Provider, SeamProviders.Finnotech) ||
Is(seams.BankOwnership.Provider, SeamProviders.Finnotech);
if (anyFinnotech)
{
services.AddHttpClient(HttpClients.Finnotech);
services.AddSingleton(sp => new FinnotechClient(
Client(sp, HttpClients.Finnotech),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>()));
}
if (Is(seams.Shahkar.Provider, SeamProviders.Finnotech))
services.AddSingleton<IShahkarVerifier>(sp => new FinnotechShahkarVerifier(sp.GetRequiredService<FinnotechClient>()));
else
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
if (Is(seams.IdentityKyc.Provider, SeamProviders.Finnotech))
services.AddSingleton<IIdentityKycProvider>(sp => new FinnotechIdentityKycProvider(sp.GetRequiredService<FinnotechClient>()));
else
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
if (Is(seams.BankOwnership.Provider, SeamProviders.Finnotech))
services.AddSingleton<IBankAccountOwnershipVerifier>(sp => new FinnotechBankAccountOwnershipVerifier(sp.GetRequiredService<FinnotechClient>()));
else
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
}
// ---- geocoding (5.4) -------------------------------------------------------------------------------------
private static void RegisterGeocoder(IServiceCollection services, SeamOptions seams)
{
if (Is(seams.Geocoding.Provider, SeamProviders.Neshan))
{
services.AddHttpClient(HttpClients.Geocoding);
services.AddSingleton<IGeocoder>(sp => new NeshanGeocoder(
Client(sp, HttpClients.Geocoding),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<NeshanGeocoder>>()));
}
else
{
services.AddSingleton<IGeocoder, MockGeocoder>();
}
}
// ---- card PSP + webhook signature + تسهیم split (6.1) ----------------------------------------------------
private static void RegisterPaymentRails(IServiceCollection services, SeamOptions seams)
{
var real = !Is(seams.Payments.Provider, SeamProviders.Mock) && !string.IsNullOrWhiteSpace(seams.Payments.Provider);
if (real)
{
services.AddHttpClient(HttpClients.Psp);
services.AddSingleton<IPaymentProvider>(sp => new ZarinPalPaymentProvider(
Client(sp, HttpClients.Psp),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<ZarinPalPaymentProvider>>()));
services.AddSingleton<ISettlementSplitProvider>(sp => new ProviderSettlementSplitProvider(
Client(sp, HttpClients.Psp),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<ProviderSettlementSplitProvider>>()));
// Per-provider HMAC over the raw callback body — never trust a callback alone (the confirm path still
// re-verifies server-side). Shared by the PSP + BNPL + payout-reconciliation callbacks.
services.AddSingleton<IWebhookVerifier, HmacWebhookVerifier>();
}
else
{
services.AddSingleton<IPaymentProvider, MockPaymentProvider>();
services.AddSingleton<ISettlementSplitProvider, MockSettlementSplitProvider>();
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
}
// Money-path mutex — in-proc today (the DB uniques/state-machine are the authoritative backstop);
// Redis-backed for >1 instance. Unchanged by the vendor swap.
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
}
// ---- BNPL (6.2) ------------------------------------------------------------------------------------------
private static void RegisterBnpl(IServiceCollection services, SeamOptions seams)
{
// The in-house net-of-fee model is always available — it stands in for the `balinyaar` provider_code even
// in real mode (no external API), and is the mock for every code in mock mode.
services.AddSingleton<MockBnplProvider>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>(); // config-driven multiplier = the real impl
if (string.Equals(seams.Bnpl.Provider, "real", StringComparison.OrdinalIgnoreCase))
{
services.AddHttpClient(HttpClients.BnplSnappPay);
services.AddHttpClient(HttpClients.BnplDigipay);
services.AddSingleton(sp => new SnappPayBnplProvider(
Client(sp, HttpClients.BnplSnappPay),
sp.GetRequiredService<ICurrencyNormalizer>(),
Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.SnappPay),
seams.Bnpl.WireCurrency,
sp.GetRequiredService<ILogger<SnappPayBnplProvider>>()));
services.AddSingleton(sp => new DigipayBnplProvider(
Client(sp, HttpClients.BnplDigipay),
sp.GetRequiredService<ICurrencyNormalizer>(),
Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.Digipay),
seams.Bnpl.WireCurrency,
sp.GetRequiredService<ILogger<DigipayBnplProvider>>()));
services.AddSingleton<IBnplProviderResolver, ConfiguredBnplProviderResolver>();
// The b11 refund `bnpl_revert` path injects IBnplProvider directly (not per-code); SnappPay is the
// default revert provider. Per-code revert resolution through the resolver is a documented follow-up.
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<SnappPayBnplProvider>());
}
else
{
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
}
}
// ---- PAYA/SATNA payout rail (6.3) ------------------------------------------------------------------------
private static void RegisterPayoutRail(IServiceCollection services, SeamOptions seams)
{
if (Is(seams.BankTransfer.Provider, SeamProviders.Jibit))
{
services.AddHttpClient(HttpClients.BankTransfer);
services.AddSingleton<IBankTransferProvider>(sp => new JibitBankTransferProvider(
Client(sp, HttpClients.BankTransfer),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<JibitBankTransferProvider>>()));
}
else
{
services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>();
}
}
// ---- Moadian e-invoicing (6.5) ---------------------------------------------------------------------------
private static void RegisterMoadian(IServiceCollection services, SeamOptions seams)
{
if (Is(seams.Moadian.Provider, SeamProviders.Moadian))
{
services.AddHttpClient(HttpClients.Moadian);
services.AddSingleton<IMoadianClient>(sp => new MoadianClient(
Client(sp, HttpClients.Moadian),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<MoadianClient>>()));
}
else
{
services.AddSingleton<IMoadianClient, MockMoadianClient>();
}
}
// ---- helpers ---------------------------------------------------------------------------------------------
private static bool Is(string? configured, string token)
=> string.Equals(configured, token, StringComparison.OrdinalIgnoreCase);
private static System.Net.Http.HttpClient Client(IServiceProvider sp, string name)
=> sp.GetRequiredService<System.Net.Http.IHttpClientFactory>().CreateClient(name);
private static string BaseOrDefault(string configured, string fallback)
=> string.IsNullOrWhiteSpace(configured) ? fallback : configured;
private static BnplProviderConnection Connection(SeamOptions seams, string code)
=> seams.Bnpl.Providers.TryGetValue(code, out var connection) ? connection : new BnplProviderConnection();
private static class HttpClients
{
public const string ObjectStorage = "seam-object-storage";
public const string Sms = "seam-sms";
public const string Telegram = "seam-sms-telegram";
public const string Finnotech = "seam-finnotech";
public const string Geocoding = "seam-geocoding";
public const string Psp = "seam-psp";
public const string BnplSnappPay = "seam-bnpl-snapppay";
public const string BnplDigipay = "seam-bnpl-digipay";
public const string BankTransfer = "seam-bank-transfer";
public const string Moadian = "seam-moadian";
}
}