Files
baya-monorepo/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/SnappPayBnplProvider.cs
T
2026-07-13 21:49:50 +03:30

202 lines
10 KiB
C#

#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;
/// <summary>
/// Real <see cref="IBnplProvider"/> for <b>SnappPay</b> (refinement-phase-8, 6.2) — the canonical superset the
/// seam was designed around: OAuth token → <c>offer/eligible</c> → <c>payment/token|verify|settle|revert|
/// cancel|update|status</c>. Resolved for <c>provider_code = snapppay</c> by <see cref="Real.ConfiguredBnplProviderResolver"/>.
/// Amounts cross the wire in the provider's currency (converted only at this boundary via the base's
/// <c>ToWire</c>/<c>FromWire</c>); the settle reads the <b>merchant commission from the actual response</b>, never
/// hardcoded, and the <c>settled_at</c> is whatever the provider reports (nullable — never assumed instant).
/// Money always flows <c>customer ↔ provider ↔ Balinyaar</c>.
///
/// <para><b>Warn:</b> this is the Iranian SnappPay provider-financed BNPL, <i>not</i> the unrelated Canadian
/// <c>SnapPayInc/open-api-java-sdk</c>. Client id/secret come from the encrypted <c>payment_gateways.config_json</c>
/// in a full deployment; the base URL + non-secret facts are in <c>Seams:Bnpl:Providers[snapppay]</c>.</para>
/// </summary>
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<BnplEligibilityResult> 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<BnplTokenResult> 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<BnplVerifyResult> 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<BnplSettleResult> 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<BnplStatusResult> 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<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> ReverseAsync("api/online/payment/v1/cancel", new { paymentToken = externalPaymentToken }, cancellationToken);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> ReverseAsync("api/online/payment/v1/revert", new { paymentToken = providerOrderReference }, cancellationToken);
public ValueTask<BnplRevertResult> 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<BnplRevertResult> 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<JsonDocument?> 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<string> 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();
}
}
}