202 lines
10 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|