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 { /// /// Registers the cross-cutting + vendor-rail seams. Each rail is config-selected (refinement-phase-8): /// the deterministic mock is the default, and setting the rail's Seams:*:Provider to a real provider /// token swaps in the real HTTP adapter behind the same Application contract — callers never change. 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 /// Seams:* (appsettings/environment) and get an from the /// IHttpClientFactory. (The real in-app INotificationDispatcher needs the database, so it is /// registered in the Persistence layer.) /// public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(SeamOptions.SectionName)); var seams = configuration.GetSection(SeamOptions.SectionName).Get() ?? new SeamOptions(); services.AddHttpClient(); services.AddMemoryCache(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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(); // Non-vendor mocks that stay as-is (real behaviour is out of this phase's scope / manual is the intended MVP). services.AddSingleton(); // 5.6 manual = intended MVP services.AddSingleton(); services.AddSingleton(); // 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(sp => new S3ObjectStorage( Client(sp, HttpClients.ObjectStorage), sp.GetRequiredService>())); } else { services.AddSingleton(); } } // ---- 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(sp => new KavenegarSmsSender( Client(sp, HttpClients.Sms), sp.GetRequiredService>(), sp.GetRequiredService>())); } 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(sp => new TelegramSmsSender( Client(sp, HttpClients.Telegram), sp.GetRequiredService>(), sp.GetRequiredService>())); } 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(); } } // ---- 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>())); } if (Is(seams.Shahkar.Provider, SeamProviders.Finnotech)) services.AddSingleton(sp => new FinnotechShahkarVerifier(sp.GetRequiredService())); else services.AddSingleton(); if (Is(seams.IdentityKyc.Provider, SeamProviders.Finnotech)) services.AddSingleton(sp => new FinnotechIdentityKycProvider(sp.GetRequiredService())); else services.AddSingleton(); if (Is(seams.BankOwnership.Provider, SeamProviders.Finnotech)) services.AddSingleton(sp => new FinnotechBankAccountOwnershipVerifier(sp.GetRequiredService())); else services.AddSingleton(); } // ---- geocoding (5.4) ------------------------------------------------------------------------------------- private static void RegisterGeocoder(IServiceCollection services, SeamOptions seams) { if (Is(seams.Geocoding.Provider, SeamProviders.Neshan)) { services.AddHttpClient(HttpClients.Geocoding); services.AddSingleton(sp => new NeshanGeocoder( Client(sp, HttpClients.Geocoding), sp.GetRequiredService>(), sp.GetRequiredService>())); } else { services.AddSingleton(); } } // ---- 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(sp => new ZarinPalPaymentProvider( Client(sp, HttpClients.Psp), sp.GetRequiredService>(), sp.GetRequiredService>())); services.AddSingleton(sp => new ProviderSettlementSplitProvider( Client(sp, HttpClients.Psp), sp.GetRequiredService>(), sp.GetRequiredService>())); // 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(); } else { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } // 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(); } // ---- 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(); services.AddSingleton(); // 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(), Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.SnappPay), seams.Bnpl.WireCurrency, sp.GetRequiredService>())); services.AddSingleton(sp => new DigipayBnplProvider( Client(sp, HttpClients.BnplDigipay), sp.GetRequiredService(), Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.Digipay), seams.Bnpl.WireCurrency, sp.GetRequiredService>())); services.AddSingleton(); // 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(sp => sp.GetRequiredService()); } else { services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); } } // ---- 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(sp => new JibitBankTransferProvider( Client(sp, HttpClients.BankTransfer), sp.GetRequiredService>(), sp.GetRequiredService>())); } else { services.AddSingleton(); } } // ---- 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(sp => new MoadianClient( Client(sp, HttpClients.Moadian), sp.GetRequiredService>(), sp.GetRequiredService>())); } else { services.AddSingleton(); } } // ---- 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().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"; } }