#nullable enable using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Baya.Application.Contracts.Payments; using Baya.Domain.Entities.Bnpl; using Microsoft.Extensions.Logging; namespace Baya.Infrastructure.CrossCutting.Seams.Real; /// /// Real for SnappPay (refinement-phase-8, 6.2) — the canonical superset the /// seam was designed around: OAuth token → offer/eligiblepayment/token|verify|settle|revert| /// cancel|update|status. Resolved for provider_code = snapppay by . /// Amounts cross the wire in the provider's currency (converted only at this boundary via the base's /// ToWire/FromWire); the settle reads the merchant commission from the actual response, never /// hardcoded, and the settled_at is whatever the provider reports (nullable — never assumed instant). /// Money always flows customer ↔ provider ↔ Balinyaar. /// /// Warn: this is the Iranian SnappPay provider-financed BNPL, not the unrelated Canadian /// SnapPayInc/open-api-java-sdk. Client id/secret come from the encrypted payment_gateways.config_json /// in a full deployment; the base URL + non-secret facts are in Seams:Bnpl:Providers[snapppay]. /// public sealed class SnappPayBnplProvider : HttpBnplProviderBase, IBnplProvider { private const string DefaultBaseUrl = "https://fms-gateway-staging.apps.public.teh-1.snappcloud.io"; private readonly BnplProviderConnection _connection; private readonly ILogger _logger; private string? _token; private DateTime _tokenExpiresAt; private readonly SemaphoreSlim _tokenGate = new(1, 1); public SnappPayBnplProvider( HttpClient httpClient, ICurrencyNormalizer currency, BnplProviderConnection connection, string wireCurrency, ILogger logger) : base(httpClient, currency, wireCurrency) { _connection = connection; _logger = logger; } private string BaseUrl => string.IsNullOrWhiteSpace(_connection.BaseUrl) ? DefaultBaseUrl : _connection.BaseUrl.TrimEnd('/'); public async ValueTask CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default) { var body = new { amount = ToWire(orderAmountIrr), mobile = customerMobile }; using var doc = await PostAsync("api/online/offer/v1/eligible", body, cancellationToken); var eligible = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True; long? ceiling = null; if (doc?.RootElement.TryGetProperty("response", out var resp) == true && resp.TryGetProperty("titleAmount", out var ceilingEl) && ceilingEl.TryGetInt64(out var c)) ceiling = FromWire(c); return new BnplEligibilityResult( eligible ? BnplEligibilityStatus.Eligible : BnplEligibilityStatus.NotEligible, InstallmentCount: 4, CreditCeilingIrr: ceiling, PlanSummary: "4 interest-free installments, provider-financed (SnappPay)."); } public async ValueTask CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default) { var body = new { amount = ToWire(orderAmountIrr), mobile = customerMobile, paymentMethodTypeDto = "INSTALLMENT", transactionId = idempotencyKey, returnURL = string.Empty, }; using var doc = await PostAsync("api/online/payment/v1/token", body, cancellationToken); var response = doc?.RootElement.TryGetProperty("response", out var r) == true ? r : default; var token = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("paymentToken", out var t) ? t.GetString() ?? string.Empty : string.Empty; var redirect = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("paymentPageUrl", out var u) ? u.GetString() ?? string.Empty : string.Empty; return string.IsNullOrEmpty(token) ? new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null) : new BnplTokenResult(PaymentProviderStatus.Succeeded, token, redirect, idempotencyKey); } public async ValueTask VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default) { using var doc = await PostAsync("api/online/payment/v1/verify", new { paymentToken = externalPaymentToken }, cancellationToken); var ok = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True; return new BnplVerifyResult(ok ? PaymentProviderStatus.Succeeded : PaymentProviderStatus.Failed, expectedOrderAmountIrr, externalPaymentToken); } public async ValueTask SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default) { using var doc = await PostAsync("api/online/payment/v1/settle", new { paymentToken = externalPaymentToken }, cancellationToken); var ok = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True; if (!ok) return new BnplSettleResult(PaymentProviderStatus.Failed, 0, 0, null, externalPaymentToken); var response = doc!.RootElement.GetProperty("response"); // The merchant discount is read from the settlement response — never hardcoded. var commissionWire = response.TryGetProperty("feeAmount", out var fee) && fee.TryGetInt64(out var f) ? f : 0; var settledWire = response.TryGetProperty("settleAmount", out var st) && st.TryGetInt64(out var stv) ? stv : ToWire(orderAmountIrr) - commissionWire; return new BnplSettleResult( PaymentProviderStatus.Succeeded, SettledAmountIrr: FromWire(settledWire), BnplCommissionIrr: FromWire(commissionWire), SettledAt: DateTime.UtcNow, ExternalTransactionId: externalPaymentToken); } public async ValueTask GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default) { using var doc = await PostAsync("api/online/payment/v1/status", new { paymentToken = externalPaymentToken }, cancellationToken); var status = doc?.RootElement.TryGetProperty("response", out var r) == true && r.TryGetProperty("status", out var st) ? st.GetString() ?? "unknown" : "unknown"; return new BnplStatusResult(status); } public ValueTask CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default) => ReverseAsync("api/online/payment/v1/cancel", new { paymentToken = externalPaymentToken }, cancellationToken); public ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default) => ReverseAsync("api/online/payment/v1/revert", new { paymentToken = providerOrderReference }, cancellationToken); public ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default) => ReverseAsync("api/online/payment/v1/update", new { paymentToken = providerOrderReference, amount = ToWire(newAmountIrr) }, cancellationToken); private async ValueTask ReverseAsync(string path, object body, CancellationToken cancellationToken) { using var doc = await PostAsync(path, body, cancellationToken); var ok = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True; if (!ok) return new BnplRevertResult(PaymentProviderStatus.Failed, null, null); long? reversedCommission = null; if (doc!.RootElement.TryGetProperty("response", out var r) && r.TryGetProperty("reversedFeeAmount", out var rf) && rf.TryGetInt64(out var rfv)) reversedCommission = FromWire(rfv); var reference = doc.RootElement.TryGetProperty("response", out var rr) && rr.TryGetProperty("trackingCode", out var tc) ? tc.GetString() : null; return new BnplRevertResult(PaymentProviderStatus.Succeeded, reference, reversedCommission); } private async ValueTask PostAsync(string path, object body, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/{path}") { Content = JsonContent.Create(body), }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetTokenAsync(cancellationToken)); using var response = await Http.SendAsync(request, cancellationToken); var raw = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogWarning("SnappPay {Path} returned http {Http}", path, (int)response.StatusCode); return null; } return JsonDocument.Parse(raw); } private async ValueTask GetTokenAsync(CancellationToken cancellationToken) { if (_token is not null && DateTime.UtcNow < _tokenExpiresAt) return _token; await _tokenGate.WaitAsync(cancellationToken); try { if (_token is not null && DateTime.UtcNow < _tokenExpiresAt) return _token; using var response = await Http.PostAsJsonAsync($"{BaseUrl}/api/online/v1/oauth/token", new { grant_type = "client_credentials", scope = "online-merchant" }, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var root = doc.RootElement; _token = root.GetProperty("access_token").GetString(); var ttl = root.TryGetProperty("expires_in", out var exp) && exp.TryGetInt32(out var seconds) ? seconds : 3600; _tokenExpiresAt = DateTime.UtcNow.AddSeconds(Math.Max(60, ttl - 60)); return _token!; } finally { _tokenGate.Release(); } } }