refinement phase 8

This commit is contained in:
hamid
2026-07-13 21:49:50 +03:30
parent 7edadadea1
commit ef3024ef2f
35 changed files with 2505 additions and 71 deletions
+24
View File
@@ -124,6 +124,30 @@ Application reference Infrastructure or the API — this is a hard rule.
real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env,
never committed). Swapping is a registration change; **no handler is touched**. The adapters:
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real provider is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
+ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
`JibitBankTransferProvider` (`BankTransfer:Provider=jibit`**async rail**: accepts as `submitted`, the
reconciliation callback `POST webhooks/payouts/{provider}``ReconcilePayoutBatchCommand` [HMAC-verified] flips
`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
config-driven (the real impl). See the mocks-registry for the per-rail config keys.
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Text;
using Asp.Versioning;
using Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The async PAYA/SATNA payout <b>reconciliation callback</b> (refinement-phase-8, 6.3). The real bank rail
/// accepts a payout as <c>submitted</c> and calls back later with the settled outcome, flipping each payout
/// <c>submitted → paid/failed</c> (the mock rail collapsed this into the submit). Authenticated by
/// <b>signature</b>, not a user session, so it is anonymous to the auth pipeline; the callback is HMAC-verified and
/// idempotent (a replayed callback re-driving an already-settled payout is a no-op). Shares the deliberate
/// bursty-tolerant <c>webhook</c> rate policy with the PSP/BNPL callbacks.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/webhooks")]
[AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "PAYA/SATNA payout reconciliation callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksPayoutsController(ISender sender) : BaseController
{
[HttpPost("payouts/{provider}")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Payouts(string provider, CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync(cancellationToken);
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
return OperationResult(await sender.Send(new ReconcilePayoutBatchCommand(provider, headers, rawBody), cancellationToken));
}
}
+11 -2
View File
@@ -86,10 +86,19 @@ builder.Services.AddApplicationServices()
.AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
// without an SMS gateway. Nothing here is wired in any other environment.
if (builder.Environment.IsDevelopment())
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
// or captured. Nothing here is wired in any other environment.
var smsProvider = configuration["Seams:Sms:Provider"];
var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && usingMockSms)
builder.Services.AddDevelopmentOtpCapture();
// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
// confirm supersedes it in production). Re-register the succeeding mock over the production fail-closed stand-in.
if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing"))
builder.Services.AddDevelopmentPaymentCapture();
builder.Services.RegisterValidatorsAsServices();
builder.Services.AddExceptionHandler<ExceptionHandler>();
@@ -22,6 +22,11 @@ public interface IInvoiceRepository
/// Null when none is issued yet.</summary>
Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>Tracked invoices still walking the مودیان state (<c>pending</c>/<c>submitted</c>), oldest first,
/// capped at <paramref name="max"/> — the refinement-phase-8 reconciliation poll re-submits each until مودیان
/// returns the 22-digit reference (or rejects it).</summary>
Task<IReadOnlyList<Invoice>> GetUnregisteredMoadianInvoicesAsync(int max, CancellationToken cancellationToken);
Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken);
/// <summary>Reads and increments the tracked counter row and returns the reserved value. The increment is
@@ -0,0 +1,46 @@
#nullable enable
using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Invoices;
using Mediator;
namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
/// <summary>
/// Loads the unregistered invoices (tracked), (re)submits each through <see cref="IMoadianClient"/>, and applies
/// the returned status/reference via the entity's guarded <c>ApplyMoadianResult</c>. One commit at the end. A
/// transient مودیان error leaves the invoice <c>submitted</c> so the next tick retries — it is never marked failed
/// on a transient fault.
/// </summary>
internal sealed class ReconcileMoadianInvoicesCommandHandler(
IUnitOfWork unitOfWork,
IMoadianClient moadianClient)
: IRequestHandler<ReconcileMoadianInvoicesCommand, OperationResult<ReconcileMoadianResult>>
{
private const int BatchSize = 100;
public async ValueTask<OperationResult<ReconcileMoadianResult>> Handle(
ReconcileMoadianInvoicesCommand request, CancellationToken cancellationToken)
{
var invoices = await unitOfWork.InvoiceRepository.GetUnregisteredMoadianInvoicesAsync(BatchSize, cancellationToken);
var registered = 0;
foreach (var invoice in invoices)
{
var submission = new InvoiceSubmission(
invoice.InvoiceNumber, invoice.BookingId, invoice.GrossIrr, invoice.PlatformCommissionIrr, invoice.VatIrr);
var result = await moadianClient.SubmitAsync(submission, cancellationToken);
invoice.ApplyMoadianResult(result.Status, result.ReferenceNumber);
if (result.Status == MoadianStatus.Registered)
registered++;
}
if (invoices.Count > 0)
await unitOfWork.CommitAsync();
return OperationResult<ReconcileMoadianResult>.SuccessResult(new ReconcileMoadianResult(invoices.Count, registered));
}
}
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
/// <summary>
/// Walks every invoice still <c>pending</c>/<c>submitted</c> with سامانه مودیان toward its registered 22-digit
/// reference (refinement-phase-8, 6.5). Run by the Moadian reconciliation <c>IRecurringJob</c> (and available as an
/// admin override); idempotent — a (re)submission of an already-registered invoice is a no-op, and the real
/// <c>IMoadianClient</c> dedups on the invoice number so re-submitting a still-<c>submitted</c> one doubles as the
/// status poll.
/// </summary>
public sealed record ReconcileMoadianInvoicesCommand : IRequest<OperationResult<ReconcileMoadianResult>>;
/// <param name="Scanned">How many unregistered invoices were examined this run.</param>
/// <param name="Registered">How many reached <c>registered</c> this run.</param>
public sealed record ReconcileMoadianResult(int Scanned, int Registered);
@@ -0,0 +1,106 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
/// <summary>
/// Verifies the callback signature (an invalid signature mutates <b>nothing</b> — an irreversible-money callback
/// fails closed), parses the per-transfer outcomes, and under <c>lock(payout:batch)</c> flips each matched
/// <c>submitted</c> payout: <c>paid</c> posts the payout ledger + nets clawbacks (reusing <see cref="PayoutSettlement"/>),
/// <c>failed</c> records the reason. Matched by <c>transfer_reference</c> (the bank track id the submit persisted).
/// Idempotent by the forward-only status machine + the ledger-exists guard, so a replayed callback never
/// double-pays or double-posts.
/// </summary>
internal sealed class ReconcilePayoutBatchCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IWebhookVerifier webhookVerifier,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ReconcilePayoutBatchCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ReconcilePayoutBatchCommand request, CancellationToken cancellationToken)
{
var verification = webhookVerifier.Verify(request.Provider, request.Headers, request.RawBody);
if (!verification.SignatureValid)
return OperationResult<bool>.FailureResult("signature", "Invalid payout callback signature; nothing was reconciled.");
if (!TryParse(request.RawBody, out var batchId, out var outcomes))
return OperationResult<bool>.FailureResult("body", "Malformed payout reconciliation callback.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(batchId, cancellationToken);
if (batch is null)
return OperationResult<bool>.NotFoundResult("Payout batch not found.");
foreach (var outcome in outcomes)
{
var payout = batch.Payouts.FirstOrDefault(p =>
p.TransferReference is not null &&
string.Equals(p.TransferReference, outcome.TransferReference, StringComparison.Ordinal));
// Only a still-submitted payout is actionable; an already-paid/failed row (replay) is skipped.
if (payout is null || payout.Status != PayoutStatus.Submitted)
continue;
if (outcome.Paid)
{
payout.MarkPaid(now);
await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
}
else
{
payout.MarkFailed(outcome.FailureReason ?? "provider_declined");
}
}
batch.RecomputeSettlement(now);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
private static bool TryParse(string rawBody, out long batchId, out List<TransferOutcome> outcomes)
{
batchId = 0;
outcomes = [];
try
{
using var doc = JsonDocument.Parse(rawBody);
var root = doc.RootElement;
if (!root.TryGetProperty("batch_id", out var batchEl) || !batchEl.TryGetInt64(out batchId))
return false;
if (root.TryGetProperty("transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array)
{
foreach (var t in transfers.EnumerateArray())
{
var reference = t.TryGetProperty("transfer_reference", out var r) ? r.GetString() : null;
if (string.IsNullOrEmpty(reference))
continue;
var status = t.TryGetProperty("status", out var s) ? s.GetString() : null;
var reason = t.TryGetProperty("failure_reason", out var fr) ? fr.GetString() : null;
outcomes.Add(new TransferOutcome(reference, string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase), reason));
}
}
return true;
}
catch (JsonException)
{
return false;
}
}
private readonly record struct TransferOutcome(string TransferReference, bool Paid, string? FailureReason);
}
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
/// <summary>
/// The async PAYA/SATNA <b>reconciliation callback</b> (refinement-phase-8, 6.3). A real payout rail accepts a
/// transfer as <c>submitted</c> (a track id, but the money hasn't confirmed) and later calls back with the settled
/// outcome; this command flips each <c>submitted</c> payout <c>paid</c>/<c>failed</c>, posting the payout ledger +
/// netting clawbacks on a paid one (the same settlement the batch execute would have done had the mock rail
/// collapsed the step). Authenticated by <b>signature</b> (not a user session) and idempotent — a replayed callback
/// re-driving an already-<c>paid</c>/<c>failed</c> payout is a no-op.
/// </summary>
/// <param name="Provider">The rail's <c>provider_code</c> (e.g. <c>jibit</c>) — selects the signing secret.</param>
/// <param name="Headers">The raw callback headers (carry the signature).</param>
/// <param name="RawBody">The verbatim callback body (HMAC-verified, then parsed for the per-transfer outcomes).</param>
public sealed record ReconcilePayoutBatchCommand(
string Provider,
IReadOnlyDictionary<string, string> Headers,
string RawBody) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,20 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// The production registration for <see cref="IPaymentCaptureSimulator"/> (refinement-phase-8, 6.4). b10's real
/// card capture superseded the simulator: in a deployed environment a booking is converted by the payment webhook
/// confirm path calling <c>ConvertRequestToBooking</c> directly on a real <c>payment_transactions.succeeded</c> —
/// <b>never</b> by fabricating a capture. So the simulator itself is a Development/Testing affordance
/// (the <c>bookings/convert</c> endpoint). This production stand-in <b>fails closed</b> — it never fabricates a
/// capture, so if the dev-only convert endpoint is somehow reached in production it cleanly creates no booking.
/// The real <see cref="MockPaymentCaptureSimulator"/> is re-registered only under Development/Testing via
/// <c>AddDevelopmentPaymentCapture</c>.
/// </summary>
public sealed class DisabledPaymentCaptureSimulator : IPaymentCaptureSimulator
{
public ValueTask<PaymentCaptureResult> ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentCaptureResult(false, string.Empty, null));
}
@@ -0,0 +1,36 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IBnplProviderResolver"/> (refinement-phase-8, 6.2) — maps each <c>provider_code</c> to its
/// concrete adapter, config-selected, <b>never an <c>if (mock)</c> in a handler</b>. Selected by
/// <c>Seams:Bnpl:Provider = real</c>.
///
/// <list type="bullet">
/// <item><c>snapppay</c> → <see cref="SnappPayBnplProvider"/>.</item>
/// <item><c>digipay</c> → <see cref="DigipayBnplProvider"/>.</item>
/// <item><c>balinyaar</c> → the <b>in-house</b> plan (<see cref="Seams.MockBnplProvider"/>). <b>REQ-022 decision:</b>
/// Balinyaar's own installment plan has no external provider API — it is financed in-house and modelled
/// identically to the external rails (a card payment landing net-of-fee, provider-financed). So it resolves
/// to the deterministic net-of-fee adapter, not an HTTP call. The distinction is the financing entity, not
/// the money mechanics.</item>
/// <item><c>tara</c> / <c>torobpay</c> → <c>null</c> (no adapter built yet) so the handler rejects them cleanly
/// rather than silently financing through the wrong provider.</item>
/// </list>
/// </summary>
public sealed class ConfiguredBnplProviderResolver(
SnappPayBnplProvider snappPay,
DigipayBnplProvider digipay,
Seams.MockBnplProvider inHouse) : IBnplProviderResolver
{
public IBnplProvider? Resolve(string providerCode) => providerCode switch
{
BnplProviderCodes.SnappPay => snappPay,
BnplProviderCodes.Digipay => digipay,
BnplProviderCodes.Balinyaar => inHouse,
_ => null,
};
}
@@ -0,0 +1,175 @@
#nullable enable
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
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>Digipay</b> (refinement-phase-8, 6.2) over the UPG installment flow
/// (<c>type=13</c>): OAuth → <c>tickets/business</c> (create) → <c>purchases/verify</c> → <c>purchases/deliver</c>
/// (settle) → <c>refunds</c> (revert). Resolved for <c>provider_code = digipay</c>. Same currency boundary as the
/// SnappPay adapter (conversion only via the base's <c>ToWire</c>/<c>FromWire</c>); the merchant commission on
/// settle is read from the deliver response, never hardcoded. Credentials come from the encrypted
/// <c>payment_gateways.config_json</c> in a full deployment; base URL + non-secret facts from
/// <c>Seams:Bnpl:Providers[digipay]</c>.
/// </summary>
public sealed class DigipayBnplProvider : HttpBnplProviderBase, IBnplProvider
{
private const string DefaultBaseUrl = "https://uat.mydigipay.info";
private readonly BnplProviderConnection _connection;
private readonly ILogger _logger;
private string? _token;
private DateTime _tokenExpiresAt;
private readonly SemaphoreSlim _tokenGate = new(1, 1);
public DigipayBnplProvider(
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('/');
// Digipay decides eligibility on its own hosted page; the UPG has no standalone pre-check, so the order is
// offered and the provider declines at the page if the customer is ineligible.
public ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplEligibilityResult(
BnplEligibilityStatus.Eligible, InstallmentCount: 4, CreditCeilingIrr: null,
PlanSummary: "Interest-free installments, provider-financed (Digipay)."));
public async ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
var body = new { amount = ToWire(orderAmountIrr), cellNumber = customerMobile, providerId = idempotencyKey, callbackUrl = string.Empty };
using var doc = await SendAsync(HttpMethod.Post, "digipay/api/tickets/business?type=13", body, cancellationToken);
if (doc is null)
return new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null);
var root = doc.RootElement;
var ticket = root.TryGetProperty("ticket", out var t) ? t.GetString() ?? string.Empty : string.Empty;
var redirect = root.TryGetProperty("redirectUrl", out var u) ? u.GetString() ?? string.Empty : string.Empty;
return string.IsNullOrEmpty(ticket)
? new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null)
: new BnplTokenResult(PaymentProviderStatus.Succeeded, ticket, redirect, idempotencyKey);
}
public async ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default)
{
using var doc = await SendAsync(HttpMethod.Post, "digipay/api/purchases/verify", new { trackingCode = externalPaymentToken }, cancellationToken);
var ok = doc?.RootElement.TryGetProperty("result", out var r) == true
&& r.TryGetProperty("status", out var st) && st.GetInt32() == 0;
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 SendAsync(HttpMethod.Post, "digipay/api/purchases/deliver?type=13",
new { invoiceNumber = externalPaymentToken, deliveryDate = DateTime.UtcNow.ToString("yyyy-MM-dd") }, cancellationToken);
var ok = doc?.RootElement.TryGetProperty("result", out var r) == true
&& r.TryGetProperty("status", out var st) && st.GetInt32() == 0;
if (!ok)
return new BnplSettleResult(PaymentProviderStatus.Failed, 0, 0, null, externalPaymentToken);
var commissionWire = doc!.RootElement.TryGetProperty("feeAmount", out var fee) && fee.TryGetInt64(out var f) ? f : 0;
var settledWire = doc.RootElement.TryGetProperty("settlementAmount", out var s) && s.TryGetInt64(out var sv)
? sv
: ToWire(orderAmountIrr) - commissionWire;
return new BnplSettleResult(
PaymentProviderStatus.Succeeded, FromWire(settledWire), FromWire(commissionWire), DateTime.UtcNow, externalPaymentToken);
}
public async ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default)
{
using var doc = await SendAsync(HttpMethod.Get, $"digipay/api/purchases/track?trackingCode={Uri.EscapeDataString(externalPaymentToken)}", null, cancellationToken);
var status = doc?.RootElement.TryGetProperty("status", out var st) == true ? st.GetString() ?? "unknown" : "unknown";
return new BnplStatusResult(status);
}
public ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> RefundAsync(externalPaymentToken, null, cancellationToken);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> RefundAsync(providerOrderReference, ToWire(amountIrr), cancellationToken);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> RefundAsync(providerOrderReference, ToWire(newAmountIrr), cancellationToken);
private async ValueTask<BnplRevertResult> RefundAsync(string reference, long? wireAmount, CancellationToken cancellationToken)
{
object body = wireAmount is null
? new { trackingCode = reference }
: new { trackingCode = reference, amount = wireAmount.Value };
using var doc = await SendAsync(HttpMethod.Post, "digipay/api/refunds", body, cancellationToken);
var ok = doc?.RootElement.TryGetProperty("result", out var r) == true
&& r.TryGetProperty("status", out var st) && st.GetInt32() == 0;
if (!ok)
return new BnplRevertResult(PaymentProviderStatus.Failed, null, null);
var refundRef = doc!.RootElement.TryGetProperty("refundTrackingCode", out var rt) ? rt.GetString() : null;
return new BnplRevertResult(PaymentProviderStatus.Succeeded, refundRef, null);
}
private async ValueTask<JsonDocument?> SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, $"{BaseUrl}/{path}");
if (body is not null)
request.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("Digipay {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 request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/digipay/api/oauth/token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["grant_type"] = "client_credentials" }),
};
// Digipay authenticates the token call with HTTP Basic (merchant client id:secret from the gateway config).
request.Headers.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_connection.MerchantId}:")));
using var response = await Http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
_token = doc.RootElement.GetProperty("access_token").GetString();
var ttl = doc.RootElement.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();
}
}
}
@@ -0,0 +1,48 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IBankAccountOwnershipVerifier"/> over the Finnotech استعلام شبا owner ↔ national-id inquiry
/// (refinement-phase-8, 5.3 — the b13 first-payout money-mule gate). Selected by
/// <c>Seams:BankOwnership:Provider = finnotech</c>. Resolves the IBAN's registered owner and matches it against
/// the nurse's national id; persists the vendor track id.
/// </summary>
public sealed class FinnotechBankAccountOwnershipVerifier(FinnotechClient client) : IBankAccountOwnershipVerifier
{
public async Task<OwnershipInquiryResult> VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default)
{
var trackId = FinnotechClient.NewTrackId();
var vendorRef = $"FINNOTECH-SHEBA-{trackId}";
var path = $"oak/v2/clients/{client.ClientId}/ibanToNid" +
$"?iban={Uri.EscapeDataString(Normalize(iban))}&trackId={trackId}";
var (document, _) = await client.GetAsync(path, cancellationToken);
using var _doc = document;
var root = document.RootElement;
string ownerName = string.Empty;
string? ownerNid = null;
if (root.TryGetProperty("result", out var result))
{
if (result.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String)
ownerName = name.GetString() ?? string.Empty;
if (result.TryGetProperty("nationalCode", out var nid) && nid.ValueKind == JsonValueKind.String)
ownerNid = nid.GetString();
}
// Ownership matches when the account's registered national code equals the nurse's. If the vendor did not
// return a national code we cannot assert a match — fail closed (matched = false), since this is the gate.
var matched = !string.IsNullOrEmpty(ownerNid)
&& !string.IsNullOrEmpty(nurseNationalId)
&& string.Equals(ownerNid, nurseNationalId, StringComparison.Ordinal);
return new OwnershipInquiryResult(matched, ownerName, vendorRef);
}
private static string Normalize(string iban)
=> string.IsNullOrEmpty(iban) ? string.Empty : iban.Replace(" ", string.Empty).ToUpperInvariant();
}
@@ -0,0 +1,39 @@
#nullable enable
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Thin shared client for the Finnotech-class KYC bridge that fronts three trust rails (شاهکار / e-KYC /
/// استعلام شبا). Owns the base address, the bearer-token authentication, and a per-call <c>trackId</c> — the
/// three seam adapters differ only in the resource path + response mapping. Credentials come from
/// <c>Seams:Finnotech</c>; a deployment supplies a current access token (the client-credential token exchange is
/// a deploy-time concern, out of the MVP adapter's scope).
/// </summary>
public sealed class FinnotechClient(HttpClient httpClient, IOptions<SeamOptions> options)
{
private const string DefaultBaseUrl = "https://apibeta.finnotech.ir";
private readonly FinnotechOptions _options = options.Value.Finnotech;
public string ClientId => _options.ClientId;
/// <summary>A fresh, unique inquiry track id (Finnotech requires one per call; kept for audit).</summary>
public static string NewTrackId() => Guid.NewGuid().ToString("N");
/// <summary>Issues an authenticated GET and returns the parsed body + the raw JSON (persisted as
/// <c>external_response_json</c>). The base URL defaults to Finnotech's host when unset.</summary>
public async Task<(JsonDocument Document, string Raw)> GetAsync(string relativePath, CancellationToken cancellationToken)
{
var baseUrl = string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/{relativePath.TrimStart('/')}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.AccessToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
return (JsonDocument.Parse(raw), raw);
}
}
@@ -0,0 +1,45 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IIdentityKycProvider"/> over the Finnotech civil-registry (ثبت احوال) inquiry
/// (refinement-phase-8, 5.2). Selected by <c>Seams:IdentityKyc:Provider = finnotech</c>. Verifies the national
/// id is valid and returns the civil-registry full name used for the later credential cross-check.
///
/// <para><b>Liveness scope.</b> This MVP adapter performs the national-id + name inquiry (the deterministic KYC
/// gate). Photo/video liveness is a distinct vendor product; it is added by extending this same adapter to also
/// submit the <paramref name="livenessPayload"/> to the liveness endpoint and folding its verdict into
/// <see cref="IdentityKycResult.Passed"/> — the seam contract does not change.</para>
/// </summary>
public sealed class FinnotechIdentityKycProvider(FinnotechClient client) : IIdentityKycProvider
{
public async Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default)
{
var trackId = FinnotechClient.NewTrackId();
var vendorRef = $"FINNOTECH-KYC-{trackId}";
var path = $"kyc/v2/clients/{client.ClientId}/nidVerification" +
$"?nationalCode={Uri.EscapeDataString(nationalId)}&trackId={trackId}";
var (document, raw) = await client.GetAsync(path, cancellationToken);
using var _ = document;
var root = document.RootElement;
var passed = root.TryGetProperty("result", out var result)
&& result.TryGetProperty("nidVerified", out var verified)
&& verified.ValueKind == JsonValueKind.True;
string? matchedName = null;
if (passed && result.TryGetProperty("fullName", out var fullName) && fullName.ValueKind == JsonValueKind.String)
matchedName = fullName.GetString();
return new IdentityKycResult(
Passed: passed,
MatchedName: matchedName,
VendorRef: vendorRef,
ExternalResponseJson: raw,
FailureReason: passed ? null : "Identity could not be verified against the civil registry.");
}
}
@@ -0,0 +1,42 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IShahkarVerifier"/> over the Finnotech شاهکار phone↔national-id inquiry (refinement-phase-8,
/// 5.2). Selected by <c>Seams:Shahkar:Provider = finnotech</c>. Maps the vendor's boolean match to
/// <see cref="ShahkarMatchResult"/> and persists the raw response for audit.
///
/// <para><b>Shared-SIM is a handled state, not derivable from Shahkar alone.</b> The شاهکار registry only asserts
/// whether the SIM is bound to the national id — it does not distinguish "owned by a family member" from a plain
/// mismatch. So a real no-match is reported as a plain mismatch (<c>IsSharedSim = false</c>) with a non-accusatory
/// reason; the explicit shared-SIM branch stays reachable through the mock (and any future vendor that surfaces
/// the ownership relationship). The handler's downstream behaviour is unchanged either way.</para>
/// </summary>
public sealed class FinnotechShahkarVerifier(FinnotechClient client) : IShahkarVerifier
{
public async Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default)
{
var trackId = FinnotechClient.NewTrackId();
var vendorRef = $"FINNOTECH-SHAHKAR-{trackId}";
var path = $"mpg/v2/clients/{client.ClientId}/shahkar/verify" +
$"?mobile={Uri.EscapeDataString(phoneNumber ?? string.Empty)}" +
$"&nationalCode={Uri.EscapeDataString(nationalId ?? string.Empty)}" +
$"&trackId={trackId}";
var (document, raw) = await client.GetAsync(path, cancellationToken);
using var _ = document;
var matched = document.RootElement.TryGetProperty("result", out var result)
&& result.TryGetProperty("isValid", out var isValid)
&& isValid.ValueKind == System.Text.Json.JsonValueKind.True;
return new ShahkarMatchResult(
Matched: matched,
IsSharedSim: false,
VendorRef: vendorRef,
ExternalResponseJson: raw,
FailureReason: matched ? null : "The phone number is not registered to your national ID.");
}
}
@@ -0,0 +1,71 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IWebhookVerifier"/> (refinement-phase-8, 6.1). Verifies an inbound PSP/BNPL callback's
/// authenticity by an <b>HMAC-SHA256 over the raw body</b> against the per-provider signing secret
/// (<c>Seams:Payments:WebhookSigningSecrets[{provider}]</c>), read from the <c>Seams:Payments:SignatureHeader</c>
/// header. Selected by <c>Seams:Payments:Provider</c> together with the PSP + settlement adapters. An invalid
/// signature yields <c>SignatureValid = false</c> so the handler stores the event ignored and <b>no money moves</b>.
///
/// <para><b>Signatureless fallback.</b> When no signing secret is configured for a provider, the signature can't
/// be checked here — the guard is the handler's mandatory server-side <c>verify</c> re-check on every success
/// event (<c>IPaymentProvider.VerifyAsync</c>), exactly as the contract prescribes. The event-id/type/reference
/// extraction is unchanged from the mock, so <c>HandlePaymentWebhook</c>'s upsert-first/no-op-on-duplicate
/// ordering is untouched.</para>
/// </summary>
public sealed class HmacWebhookVerifier(IOptions<SeamOptions> options) : IWebhookVerifier
{
private readonly PaymentsOptions _options = options.Value.Payments;
public WebhookVerification Verify(string provider, IReadOnlyDictionary<string, string> headers, string rawBody)
{
var signatureValid = VerifySignature(provider, headers, rawBody);
string externalEventId = string.Empty;
string eventType = string.Empty;
string? gatewayReferenceCode = null;
try
{
using var doc = JsonDocument.Parse(rawBody);
var root = doc.RootElement;
if (root.TryGetProperty("external_event_id", out var id))
externalEventId = id.GetString() ?? string.Empty;
if (root.TryGetProperty("event_type", out var type))
eventType = type.GetString() ?? string.Empty;
if (root.TryGetProperty("gateway_reference_code", out var reference))
gatewayReferenceCode = reference.GetString();
}
catch (JsonException)
{
// Unparseable body → empty id/type; the handler stores it and no-ops (nothing to confirm).
}
var isSuccessEvent = eventType.Contains("succeed", StringComparison.OrdinalIgnoreCase);
return new WebhookVerification(signatureValid, externalEventId, eventType, gatewayReferenceCode, isSuccessEvent);
}
private bool VerifySignature(string provider, IReadOnlyDictionary<string, string> headers, string rawBody)
{
if (!_options.WebhookSigningSecrets.TryGetValue(provider, out var secret) || string.IsNullOrEmpty(secret))
return true; // no secret configured → the server-side verify re-check is the guard (contract fallback).
if (!headers.TryGetValue(_options.SignatureHeader, out var provided) || string.IsNullOrEmpty(provided))
return false; // a secret is configured but the callback carried no signature → reject.
var computed = Convert.ToHexStringLower(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(rawBody)));
// Constant-time compare; tolerate a provider that prefixes the scheme (e.g. "sha256=...").
var candidate = provided.Contains('=') ? provided[(provided.IndexOf('=') + 1)..] : provided;
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed), Encoding.UTF8.GetBytes(candidate.ToLowerInvariant()));
}
}
@@ -0,0 +1,27 @@
#nullable enable
using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Shared base for the real HTTP BNPL adapters (refinement-phase-8, 6.2). Owns the <b>currency boundary</b> — the
/// one place Toman↔IRR conversion is allowed — via <see cref="ICurrencyNormalizer"/>: every amount leaving the
/// domain is turned into the provider's wire currency by <see cref="ToWire"/>, and every amount coming back is
/// normalized to IRR by <see cref="FromWire"/>. Conversion happens <b>only</b> here, never internally. Concrete
/// adapters (<see cref="SnappPayBnplProvider"/>, <see cref="DigipayBnplProvider"/>) implement the provider's verb
/// set; a real deployment reads the provider's wire currency from <c>Seams:Bnpl:WireCurrency</c> (SnappPay/Digipay
/// settle in Rial, so the default is a pass-through).
/// </summary>
public abstract class HttpBnplProviderBase(HttpClient httpClient, ICurrencyNormalizer currency, string wireCurrency)
{
protected HttpClient Http { get; } = httpClient;
/// <summary>Domain IRR → the provider's wire amount (Rial pass-through, or Toman when the provider speaks Toman).</summary>
protected long ToWire(long amountIrr)
=> string.Equals(wireCurrency, "TOMAN", StringComparison.OrdinalIgnoreCase)
? currency.ToDisplayToman(amountIrr)
: amountIrr;
/// <summary>A provider wire amount → domain IRR (the only inbound conversion point).</summary>
protected long FromWire(long wireAmount) => currency.ToIrr(wireAmount, wireCurrency);
}
@@ -0,0 +1,129 @@
#nullable enable
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IBankTransferProvider"/> over the <b>Jibit</b> PAYA/SATNA payout rail (refinement-phase-8, 6.3).
/// Selected by <c>Seams:BankTransfer:Provider = jibit</c>. Registers each payout as a transfer from the platform's
/// registered <b>source settlement account</b> to the nurse's verified Sheba, honouring the PAYA/SATNA
/// <see cref="PayoutInstruction.Method"/> the handler chose by the config threshold. <b>The real rail is async</b>:
/// an accepted transfer comes back <see cref="BankTransferStatus.Submitted"/> (a track id is issued but the money
/// hasn't confirmed) — the execute handler already leaves such a payout <c>submitted</c> and posts no ledger until
/// the async reconciliation callback (<c>POST webhooks/payouts/jibit</c>) flips it <c>paid</c>/<c>failed</c>.
///
/// <para>The <c>idempotencyKey</c> (<c>payout-batch:{id}</c>) is passed as the client reference so a retried submit
/// never re-sends an already-accepted transfer. Every amount is IRR <c>long</c>. Credentials from
/// <c>Seams:BankTransfer</c>; the exact transfer payload is confirmed against the transferor at integration.</para>
/// </summary>
public sealed class JibitBankTransferProvider(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<JibitBankTransferProvider> logger) : IBankTransferProvider
{
private const string DefaultBaseUrl = "https://napi.jibit.ir/trf";
private readonly BankTransferOptions _options = options.Value.BankTransfer;
private string BaseUrl => string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
public async ValueTask<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
long payoutBatchId,
IReadOnlyList<PayoutInstruction> instructions,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var payload = new
{
batchID = idempotencyKey,
submissionMode = "TRANSFER",
transfers = instructions.Select(i => new
{
transferID = $"{idempotencyKey}:{i.PayoutId}",
destination = i.Iban,
amount = i.AmountIrr,
currency = "IRR",
transferMode = MapMode(i.Method),
sourceIdentifier = _options.SourceSettlementAccount,
}),
};
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/transfers")
{
Content = JsonContent.Create(payload),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
using var response = await httpClient.SendAsync(request, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Jibit payout submit returned http {Http} for batch {BatchId}", (int)response.StatusCode, payoutBatchId);
// Whole-batch rejection — every instruction fails so the batch reports failed/retryable.
return new PayoutBatchSubmitResult(
ExternalBatchRef: idempotencyKey,
Results: instructions
.Select(i => new PayoutInstructionResult(i.PayoutId, BankTransferStatus.Failed, null, i.Method, "provider_rejected_batch"))
.ToList());
}
using var doc = JsonDocument.Parse(raw);
var perTransfer = ReadTransferStates(doc);
var results = instructions.Select(i =>
{
var transferId = $"{idempotencyKey}:{i.PayoutId}";
var (accepted, track) = perTransfer.GetValueOrDefault(transferId, (true, transferId));
return accepted
? new PayoutInstructionResult(i.PayoutId, BankTransferStatus.Submitted, track, i.Method, null)
: new PayoutInstructionResult(i.PayoutId, BankTransferStatus.Failed, null, i.Method, "provider_declined");
}).ToList();
return new PayoutBatchSubmitResult(idempotencyKey, results);
}
public async ValueTask<BankTransferStatus> GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/transfers?batchID={Uri.EscapeDataString(externalBatchRef)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return BankTransferStatus.Submitted; // unknown yet — stay submitted, the callback is authoritative.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
return MapState(doc.RootElement.TryGetProperty("state", out var s) ? s.GetString() : null);
}
private static Dictionary<string, (bool Accepted, string? Track)> ReadTransferStates(JsonDocument doc)
{
var map = new Dictionary<string, (bool, string?)>(StringComparer.Ordinal);
if (doc.RootElement.TryGetProperty("transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array)
{
foreach (var t in transfers.EnumerateArray())
{
var id = t.TryGetProperty("transferID", out var tid) ? tid.GetString() : null;
if (id is null) continue;
var accepted = MapState(t.TryGetProperty("state", out var st) ? st.GetString() : null) != BankTransferStatus.Failed;
var track = t.TryGetProperty("bankTransferID", out var bt) ? bt.GetString() : id;
map[id] = (accepted, track);
}
}
return map;
}
private static BankTransferStatus MapState(string? state) => state?.ToUpperInvariant() switch
{
"TRANSFERRED" or "SETTLED" or "PAID" => BankTransferStatus.Paid,
"FAILED" or "CANCELLED" or "REJECTED" => BankTransferStatus.Failed,
_ => BankTransferStatus.Submitted,
};
private static string MapMode(string method)
=> string.Equals(method, BankTransferMethod.Satna, StringComparison.OrdinalIgnoreCase) ? "SATNA" : "ACH";
}
@@ -0,0 +1,102 @@
#nullable enable
using System.Net;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="ISmsSender"/> over the <b>Kavenegar</b> Iranian SMS gateway (refinement-phase-8, 5.1 —
/// launch-critical). OTP delivery uses Kavenegar's <c>verify/lookup</c> pattern API (a pre-approved template,
/// no marketing pre-clearance needed for transactional OTPs); free-form transactional messages use
/// <c>sms/send</c> from the registered sender line. Selected by <c>Seams:Sms:Provider = kavenegar</c>; the seam
/// is swapped by a registration change only, so no handler is touched.
///
/// <para><b>The OTP is never logged.</b> Once this ships the Development OTP-in-logs/echo bridge is disabled
/// (the code only leaves the process over the SMS wire) — only the phone tail + gateway status are logged.</para>
/// </summary>
public sealed class KavenegarSmsSender(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<KavenegarSmsSender> logger) : ISmsSender
{
private readonly SmsOptions _options = options.Value.Sms;
public async Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
{
// verify/lookup: the code is delivered through the approved OTP template — never a free-form message,
// which is what keeps transactional OTPs deliverable without marketing pre-clearance.
var query = new Dictionary<string, string?>
{
["receptor"] = phone,
["token"] = code,
["template"] = _options.OtpTemplate,
};
await SendAsync($"v1/{_options.ApiKey}/verify/lookup.json", query, phone, cancellationToken);
}
public async Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
{
var query = new Dictionary<string, string?>
{
["receptor"] = phone,
["sender"] = _options.SenderLine,
["message"] = message,
};
await SendAsync($"v1/{_options.ApiKey}/sms/send.json", query, phone, cancellationToken);
}
private async Task SendAsync(
string path, IReadOnlyDictionary<string, string?> query, string phone, CancellationToken cancellationToken)
{
var url = QueryHelpers(path, query);
using var response = await httpClient.GetAsync(url, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
// Kavenegar always returns 200 for a well-formed request and carries the real outcome in `return.status`
// (200 = accepted). A non-accepted status (e.g. 411 invalid receptor, 418 credit) is a delivery failure —
// surfaced as an exception so the OTP command reports the send failure rather than silently "succeeding".
var status = TryReadReturnStatus(body);
if (response.StatusCode != HttpStatusCode.OK || status is not 200)
{
logger.LogWarning(
"Kavenegar SMS delivery failed for phone ending {PhoneTail} — http {Http}, return status {ReturnStatus}",
Tail(phone), (int)response.StatusCode, status);
throw new InvalidOperationException($"Kavenegar SMS delivery failed (return status {status}).");
}
logger.LogInformation("Kavenegar SMS accepted for phone ending {PhoneTail}", Tail(phone));
}
private static int? TryReadReturnStatus(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("return", out var ret)
&& ret.TryGetProperty("status", out var status)
? status.GetInt32()
: null;
}
catch (JsonException)
{
return null;
}
}
private static string QueryHelpers(string path, IReadOnlyDictionary<string, string?> query)
{
var pairs = query
.Where(kvp => kvp.Value is not null)
.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}");
return $"{path}?{string.Join('&', pairs)}";
}
private static string Tail(string phone) =>
string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..];
}
@@ -0,0 +1,86 @@
#nullable enable
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Baya.Application.Contracts.Invoices;
using Baya.Domain.Entities.Invoices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IMoadianClient"/> over سامانه مودیان (refinement-phase-8, 6.5). Selected by
/// <c>Seams:Moadian:Provider = moadian</c>. Submits the commission invoice (صورتحساب) to the tax-authority API and
/// maps the outcome: a returned 22-digit reference ⇒ <see cref="MoadianStatus.Registered"/>; an accepted-but-not-
/// yet-registered submission ⇒ <see cref="MoadianStatus.Submitted"/> (the reconciliation poll walks it to
/// <c>registered</c>); a rejection ⇒ <see cref="MoadianStatus.Failed"/>.
///
/// <para><b>Idempotent submit.</b> The invoice number is sent as the unique document id, so مودیان dedups a
/// re-submission — which is what lets the reconciliation job safely re-call this for a still-<c>submitted</c>
/// invoice (the seam has one verb; a re-submit doubles as the status poll). <b>Enrollment + the signing
/// certificate</b> (memory/economic id, تعهدنامه) are a deploy-time concern — a deployment supplies the memory id
/// + a current token via <c>Seams:Moadian</c>; signing the payload with the platform's private key is added inside
/// this adapter at integration without changing the seam.</para>
/// </summary>
public sealed class MoadianClient(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<MoadianClient> logger) : IMoadianClient
{
private const string DefaultBaseUrl = "https://tp.tax.gov.ir";
private readonly MoadianOptions _options = options.Value.Moadian;
private string BaseUrl => string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
public async ValueTask<MoadianSubmissionResult> SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default)
{
var payload = new
{
memoryId = _options.MemoryId,
invoiceNumber = submission.InvoiceNumber,
bookingId = submission.BookingId,
totalAmount = submission.GrossIrr,
commissionAmount = submission.PlatformCommissionIrr,
vatAmount = submission.VatIrr,
};
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/req/api/self-tsp/sync/normal-enveloped")
{
Content = JsonContent.Create(payload),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.AccessToken);
try
{
using var response = await httpClient.SendAsync(request, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("مودیان submission returned http {Http} for invoice {InvoiceNumber}",
(int)response.StatusCode, submission.InvoiceNumber);
return new MoadianSubmissionResult(MoadianStatus.Failed, null);
}
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
// A returned reference number registers the invoice; otherwise it was accepted and is awaiting the ref.
var reference = root.TryGetProperty("referenceNumber", out var refEl) ? refEl.GetString()
: root.TryGetProperty("uid", out var uid) ? uid.GetString()
: null;
return string.IsNullOrEmpty(reference)
? new MoadianSubmissionResult(MoadianStatus.Submitted, null)
: new MoadianSubmissionResult(MoadianStatus.Registered, reference);
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
// A مودیان outage leaves the invoice submittable again on the next reconciliation tick — never failed
// permanently on a transient error.
logger.LogWarning(ex, "مودیان submission failed transiently for invoice {InvoiceNumber}", submission.InvoiceNumber);
return new MoadianSubmissionResult(MoadianStatus.Submitted, null);
}
}
}
@@ -0,0 +1,87 @@
#nullable enable
using System.Globalization;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IGeocoder"/> over the <b>Neshan</b> geocoding API (refinement-phase-8, 5.4) — turns a typed
/// address into coordinates for the b9 EVV distance check. Selected by <c>Seams:Geocoding:Provider = neshan</c>.
/// Coordinates are parsed to <see cref="decimal"/> (never float) so the downstream haversine stays exact. An
/// address Neshan can't resolve yields null coordinates (the "saved without a map pin" state) rather than an
/// error — the address is still saved. REQ-008's user-supplied pin reduces, but doesn't remove, the need for this.
/// </summary>
public sealed class NeshanGeocoder(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<NeshanGeocoder> logger) : IGeocoder
{
private const string DefaultBaseUrl = "https://api.neshan.org";
private readonly GeocodingOptions _options = options.Value.Geocoding;
public async ValueTask<GeocodeResult> GeocodeAsync(
string addressText,
string cityName,
string? districtName,
CancellationToken cancellationToken = default)
{
var formatted = FormatAddress(addressText, cityName, districtName);
var baseUrl = string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
try
{
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{baseUrl}/v1/geocoding?address={Uri.EscapeDataString(formatted)}");
request.Headers.Add("Api-Key", _options.ApiKey);
using var response = await httpClient.SendAsync(request, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
var ok = root.TryGetProperty("status", out var status)
&& string.Equals(status.GetString(), "OK", StringComparison.OrdinalIgnoreCase);
if (ok && root.TryGetProperty("location", out var location)
&& location.TryGetProperty("y", out var y)
&& location.TryGetProperty("x", out var x)
&& TryDecimal(y, out var lat) && TryDecimal(x, out var lng))
{
// Neshan: x = longitude, y = latitude.
return new GeocodeResult(decimal.Round(lat, 6), decimal.Round(lng, 6), formatted, _options.ResolvedConfidence);
}
logger.LogWarning("Neshan could not resolve an address to coordinates (status {Status})",
ok ? "OK-no-location" : "not-OK");
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
// A geocoder outage must never block saving an address — degrade to the null-pin state.
logger.LogWarning(ex, "Neshan geocoding failed; saving the address without a map pin");
}
return new GeocodeResult(null, null, formatted, 0.2);
}
private static bool TryDecimal(JsonElement element, out decimal value)
{
if (element.ValueKind == JsonValueKind.Number && element.TryGetDecimal(out value))
return true;
if (element.ValueKind == JsonValueKind.String
&& decimal.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value))
return true;
value = 0m;
return false;
}
private static string FormatAddress(string addressText, string cityName, string? districtName) =>
string.Join("، ", new[] { cityName, districtName, addressText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
}
@@ -0,0 +1,78 @@
#nullable enable
using System.Net.Http.Json;
using System.Text.Json;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="ISettlementSplitProvider"/> — the تسهیم (settlement-sharing) rail (refinement-phase-8, 6.1).
/// Selected by <c>Seams:Payments:Provider</c> together with the PSP + webhook adapters. Registers a split-by-ratio
/// to the beneficiaries' <b>registered IBANs</b> (nurse payout + platform commission); the acquirer credits each
/// IBAN directly — <b>the platform never custodies funds</b>, the ledger only mirrors what legally sits at the
/// provider/bank. Amounts are IRR <c>long</c>.
///
/// <para>The exact تسهیم endpoint/payload is acquirer-specific (ZarinPal/Zibal/Vandar each differ) and honours the
/// ~100,000 IRR per-leg minimum; the request shape here is the common split-list form. A deployment confirms the
/// concrete route + credential source (the platform SHEBA from <c>Seams:Payments:PlatformSheba</c>, each nurse
/// SHEBA from the b3 <c>matched_national_id</c>-gated bank account) at integration.</para>
/// </summary>
public sealed class ProviderSettlementSplitProvider(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<ProviderSettlementSplitProvider> logger) : ISettlementSplitProvider
{
private readonly PaymentsOptions _options = options.Value.Payments;
private string BaseUrl => _options.BaseUrl.TrimEnd('/');
public async ValueTask<SettlementResult> RegisterSplitAsync(long bookingId, IReadOnlyList<SettlementLeg> legs, CancellationToken cancellationToken = default)
{
if (legs.Count == 0 || legs.Sum(l => l.AmountIrr) <= 0)
return new SettlementResult(SettlementStatus.Failed);
var payload = new
{
booking_id = bookingId,
wages = legs.Select(l => new { iban = l.Sheba, amount = l.AmountIrr, description = l.Beneficiary }),
};
try
{
using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/settlement/split", payload, cancellationToken);
response.EnsureSuccessStatusCode();
return new SettlementResult(SettlementStatus.Registered);
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "تسهیم split registration failed for booking {BookingId}", bookingId);
return new SettlementResult(SettlementStatus.Failed);
}
}
public async ValueTask<SettlementResult> GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default)
{
try
{
using var response = await httpClient.GetAsync($"{BaseUrl}/settlement/split/{bookingId}", cancellationToken);
response.EnsureSuccessStatusCode();
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(raw);
var status = doc.RootElement.TryGetProperty("status", out var s) ? s.GetString() : null;
return new SettlementResult(status?.ToLowerInvariant() switch
{
"settled" or "done" or "paid" => SettlementStatus.Settled,
"failed" or "rejected" => SettlementStatus.Failed,
_ => SettlementStatus.Registered,
});
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "تسهیم split status read failed for booking {BookingId}", bookingId);
return new SettlementResult(SettlementStatus.Registered);
}
}
}
@@ -0,0 +1,190 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IObjectStorage"/> over an S3-compatible store — MinIO / AWS S3 / ArvanCloud
/// (refinement-phase-8, 5.5). Selected by <c>Seams:ObjectStorage:Provider = s3</c>. Makes the b6 signed-URL
/// document contract and the REQ-006 avatar path real (local disk + <c>file://</c> today). Server-side
/// put/get/delete are AWS-SigV4-authenticated requests; <see cref="GetUrl"/> returns a time-limited
/// <b>presigned GET</b> so a document is fetched directly from storage without proxying bytes through the API.
///
/// <para>Signing is implemented directly against SigV4 (HMAC-SHA256, all in the BCL) so no AWS SDK dependency is
/// pulled in. Uploads use <c>UNSIGNED-PAYLOAD</c> so a blob stream is never buffered to hash it.</para>
/// </summary>
public sealed class S3ObjectStorage : IObjectStorage
{
private const string Algorithm = "AWS4-HMAC-SHA256";
private const string UnsignedPayload = "UNSIGNED-PAYLOAD";
private const string Service = "s3";
private readonly HttpClient _httpClient;
private readonly ObjectStorageOptions _options;
private readonly Uri _serviceUri;
public S3ObjectStorage(HttpClient httpClient, IOptions<SeamOptions> options)
{
_httpClient = httpClient;
_options = options.Value.ObjectStorage;
_serviceUri = new Uri(_options.ServiceUrl.TrimEnd('/'), UriKind.Absolute);
}
public async ValueTask PutAsync(string key, Stream content, string contentType, CancellationToken cancellationToken = default)
{
var (uri, host, canonicalUri) = ResolveObject(key);
using var request = new HttpRequestMessage(HttpMethod.Put, uri)
{
Content = new StreamContent(content),
};
request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
SignRequest(request, "PUT", host, canonicalUri);
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
}
public async ValueTask<Stream?> GetAsync(string key, CancellationToken cancellationToken = default)
{
var (uri, host, canonicalUri) = ResolveObject(key);
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
SignRequest(request, "GET", host, canonicalUri);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
response.Dispose();
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken);
}
public async ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default)
{
var (uri, host, canonicalUri) = ResolveObject(key);
using var request = new HttpRequestMessage(HttpMethod.Delete, uri);
SignRequest(request, "DELETE", host, canonicalUri);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode != System.Net.HttpStatusCode.NotFound)
response.EnsureSuccessStatusCode();
}
/// <summary>A presigned GET URL valid for <c>PresignExpirySeconds</c> — the real form of the b6 signed-URL contract.</summary>
public string GetUrl(string key) => Presign(key, TimeSpan.FromSeconds(_options.PresignExpirySeconds));
// ---- URL / object resolution -------------------------------------------------------------------------
private (Uri Uri, string Host, string CanonicalUri) ResolveObject(string key)
{
var encodedKey = EncodeKey(key);
if (_options.UsePathStyle)
{
var host = _serviceUri.Authority;
var canonicalUri = $"/{_options.Bucket}/{encodedKey}";
return (new Uri($"{_serviceUri.Scheme}://{host}{canonicalUri}"), host, canonicalUri);
}
else
{
var host = $"{_options.Bucket}.{_serviceUri.Authority}";
var canonicalUri = $"/{encodedKey}";
return (new Uri($"{_serviceUri.Scheme}://{host}{canonicalUri}"), host, canonicalUri);
}
}
// ---- SigV4: authenticated request (header auth) ------------------------------------------------------
private void SignRequest(HttpRequestMessage request, string method, string host, string canonicalUri)
{
var now = DateTime.UtcNow;
var amzDate = now.ToString("yyyyMMddTHHmmssZ");
var dateStamp = now.ToString("yyyyMMdd");
var scope = $"{dateStamp}/{_options.Region}/{Service}/aws4_request";
var canonicalHeaders = $"host:{host}\nx-amz-content-sha256:{UnsignedPayload}\nx-amz-date:{amzDate}\n";
const string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
var canonicalRequest = string.Join('\n',
method, canonicalUri, string.Empty, canonicalHeaders, signedHeaders, UnsignedPayload);
var stringToSign = string.Join('\n', Algorithm, amzDate, scope, Hex(Sha256(canonicalRequest)));
var signature = Hex(Hmac(SigningKey(dateStamp), stringToSign));
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", UnsignedPayload);
request.Headers.TryAddWithoutValidation("Authorization",
$"{Algorithm} Credential={_options.AccessKey}/{scope}, SignedHeaders={signedHeaders}, Signature={signature}");
}
// ---- SigV4: presigned URL (query auth) ---------------------------------------------------------------
private string Presign(string key, TimeSpan expiry)
{
var (uri, host, canonicalUri) = ResolveObject(key);
var now = DateTime.UtcNow;
var amzDate = now.ToString("yyyyMMddTHHmmssZ");
var dateStamp = now.ToString("yyyyMMdd");
var scope = $"{dateStamp}/{_options.Region}/{Service}/aws4_request";
// Query keys must be sorted; each value URL-encoded (RFC3986). host is the only signed header.
var query = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["X-Amz-Algorithm"] = Algorithm,
["X-Amz-Credential"] = $"{_options.AccessKey}/{scope}",
["X-Amz-Date"] = amzDate,
["X-Amz-Expires"] = ((int)expiry.TotalSeconds).ToString(),
["X-Amz-SignedHeaders"] = "host",
};
var canonicalQuery = string.Join('&', query.Select(kvp => $"{RfcEncode(kvp.Key)}={RfcEncode(kvp.Value)}"));
var canonicalHeaders = $"host:{host}\n";
var canonicalRequest = string.Join('\n',
"GET", canonicalUri, canonicalQuery, canonicalHeaders, "host", UnsignedPayload);
var stringToSign = string.Join('\n', Algorithm, amzDate, scope, Hex(Sha256(canonicalRequest)));
var signature = Hex(Hmac(SigningKey(dateStamp), stringToSign));
return $"{uri.Scheme}://{host}{canonicalUri}?{canonicalQuery}&X-Amz-Signature={signature}";
}
// ---- crypto primitives -------------------------------------------------------------------------------
private byte[] SigningKey(string dateStamp)
{
var kDate = Hmac(Encoding.UTF8.GetBytes($"AWS4{_options.SecretKey}"), dateStamp);
var kRegion = Hmac(kDate, _options.Region);
var kService = Hmac(kRegion, Service);
return Hmac(kService, "aws4_request");
}
private static byte[] Hmac(byte[] key, string data) => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
private static byte[] Sha256(string data) => SHA256.HashData(Encoding.UTF8.GetBytes(data));
private static string Hex(byte[] bytes) => Convert.ToHexStringLower(bytes);
// Encode an object key preserving '/' as a path separator (each segment RFC3986-encoded).
private static string EncodeKey(string key)
{
var normalized = key.Replace('\\', '/').TrimStart('/');
return string.Join('/', normalized
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(s => s != "." && s != "..")
.Select(RfcEncode));
}
// RFC 3986 unreserved set — AWS canonicalization encodes everything else.
private static string RfcEncode(string value)
{
const string unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
var sb = new StringBuilder(value.Length);
foreach (var b in Encoding.UTF8.GetBytes(value))
{
var c = (char)b;
if (unreserved.IndexOf(c) >= 0) sb.Append(c);
else sb.Append('%').Append(b.ToString("X2"));
}
return sb.ToString();
}
}
@@ -0,0 +1,201 @@
#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();
}
}
}
@@ -0,0 +1,107 @@
#nullable enable
using System.Net.Http.Json;
using System.Text.Json;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IPaymentProvider"/> over the <b>ZarinPal</b> IPG (refinement-phase-8, 6.1). Selected by
/// <c>Seams:Payments:Provider = zarinpal</c>. Opens a v4 payment session (returns the Shaparak-routed redirect +
/// the <c>authority</c> as the gateway reference), performs the <b>mandatory server-side verify</b> (never trusts
/// a callback alone — the amount is re-checked against the gateway), and reverses a captured payment.
///
/// <para>Every amount crossing this seam is IRR <c>long</c>; ZarinPal v4 amounts are Rial, so no conversion
/// happens here. The merchant id defaults from <c>Seams:Payments:MerchantId</c>; a full multi-gateway deployment
/// resolves it per gateway row from the encrypted <c>payment_gateways.config_json</c> via a provider factory —
/// the seam contract is identical either way.</para>
/// </summary>
public sealed class ZarinPalPaymentProvider(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<ZarinPalPaymentProvider> logger) : IPaymentProvider
{
private const string DefaultBaseUrl = "https://payment.zarinpal.com";
private readonly PaymentsOptions _options = options.Value.Payments;
private string BaseUrl => string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
public async ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
var payload = new
{
merchant_id = _options.MerchantId,
amount = amountIrr,
callback_url = _options.CallbackUrl,
description = $"Balinyaar booking request {bookingRequestId}",
metadata = new { idempotency_key = idempotencyKey },
};
using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/pg/v4/payment/request.json", payload, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(raw);
var authority = doc.RootElement.TryGetProperty("data", out var data)
&& data.TryGetProperty("authority", out var auth)
? auth.GetString()
: null;
if (string.IsNullOrEmpty(authority))
throw new InvalidOperationException("ZarinPal did not return a payment authority.");
return new PaymentInitResult(
RedirectUrl: $"{BaseUrl}/pg/StartPay/{authority}",
GatewayReferenceCode: authority);
}
public async ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
{
var payload = new { merchant_id = _options.MerchantId, amount = expectedAmountIrr, authority = gatewayReferenceCode };
using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/pg/v4/payment/verify.json", payload, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("ZarinPal verify returned http {Http} for authority {Authority}", (int)response.StatusCode, gatewayReferenceCode);
return new PaymentVerifyResult(PaymentProviderStatus.Failed, 0);
}
using var doc = JsonDocument.Parse(raw);
// v4 verify: data.code 100 = paid, 101 = already verified (both confirm). Anything else is a non-confirm.
var code = doc.RootElement.TryGetProperty("data", out var data) && data.TryGetProperty("code", out var c)
? c.GetInt32()
: -1;
return code is 100 or 101
? new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr)
: new PaymentVerifyResult(PaymentProviderStatus.Failed, 0);
}
public async ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
// ZarinPal reversals go through the authorized refund API; the idempotency key is carried so a retried
// refund is a no-op at the gateway rather than a double reversal.
var payload = new { merchant_id = _options.MerchantId, authority = gatewayReferenceCode, amount = amountIrr, idempotency_key = idempotencyKey };
using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/pg/v4/payment/refund.json", payload, cancellationToken);
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("ZarinPal refund returned http {Http} for authority {Authority}", (int)response.StatusCode, gatewayReferenceCode);
return new PaymentRefundResult(PaymentProviderStatus.Failed, null);
}
using var doc = JsonDocument.Parse(raw);
string? refundRef = doc.RootElement.TryGetProperty("data", out var data)
&& data.TryGetProperty("ref_id", out var refId)
? refId.ToString()
: null;
return new PaymentRefundResult(PaymentProviderStatus.Succeeded, refundRef);
}
}
@@ -3,12 +3,19 @@ namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Options bound from the <c>Seams</c> configuration section. The mock seams read non-secret defaults
/// from here; production keys/paths come from environment variables or user-secrets, never committed.
///
/// <para><b>Provider selection (refinement-phase-8).</b> Each vendor rail carries a <c>Provider</c> selector
/// (default = the mock, so an unconfigured environment behaves exactly as before). Setting it to a real
/// provider token (e.g. <c>Seams:Sms:Provider = kavenegar</c>) swaps in the real HTTP adapter behind the same
/// contract — handlers never change. This makes a <b>partial rollout</b> the normal case: real SMS + real
/// geocoder while payments stay mocked in a pre-launch environment is just three config keys.</para>
/// </summary>
public sealed class SeamOptions
{
public const string SectionName = "Seams";
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public SmsOptions Sms { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
@@ -22,6 +29,85 @@ public sealed class SeamOptions
public BankTransferOptions BankTransfer { get; set; } = new();
public ReviewModerationOptions ReviewModeration { get; set; } = new();
public LicenseVerificationOptions LicenseVerification { get; set; } = new();
public FinnotechOptions Finnotech { get; set; } = new();
}
/// <summary>
/// Shared credentials for the Finnotech-class KYC bridge that fronts three trust rails — شاهکار
/// (<c>IShahkarVerifier</c>), e-KYC (<c>IIdentityKycProvider</c>), and استعلام شبا
/// (<c>IBankAccountOwnershipVerifier</c>). Each seam opts in with its own <c>Provider = finnotech</c> selector,
/// but they authenticate against the same tenant, so the connection facts live here once. All values are
/// secrets — user-secrets / environment, never committed.
/// </summary>
public sealed class FinnotechOptions
{
/// <summary>API host (defaults to Finnotech's public sandbox/production host at the adapter).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The tenant's client id (<c>NID</c>) — the Finnotech app identifier.</summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>A pre-issued bearer access token (client-credential token exchange is out of scope for the MVP
/// adapter; a deployment supplies a current token, refreshed out-of-band).</summary>
public string AccessToken { get; set; } = string.Empty;
}
/// <summary>Stable provider tokens for the <c>Provider</c> selectors, so a typo fails closed to the mock.</summary>
public static class SeamProviders
{
public const string Mock = "mock";
public const string LocalDisk = "local";
// SMS gateways
public const string Kavenegar = "kavenegar";
public const string SmsIr = "smsir";
public const string Ghasedak = "ghasedak";
// Object storage
public const string S3 = "s3";
// Trust / identity (a Finnotech-class KYC bridge fronts Shahkar / e-KYC / استعلام شبا)
public const string Finnotech = "finnotech";
// Geocoding
public const string Neshan = "neshan";
// Card PSP acquirers
public const string ZarinPal = "zarinpal";
public const string Sadad = "sadad";
public const string Vandar = "vandar";
public const string Jibit = "jibit";
// BNPL
public const string SnappPay = "snapppay";
public const string Digipay = "digipay";
// e-invoicing
public const string Moadian = "moadian";
}
/// <summary>
/// The outbound SMS rail (<c>ISmsSender</c>). <see cref="Provider"/> = <c>mock</c> logs the OTP (the b2
/// <c>LoggingSmsSender</c>); set it to <c>kavenegar</c> / <c>smsir</c> / <c>ghasedak</c> to deliver over a real
/// Iranian gateway. <b>refinement-phase-8:</b> when a real provider is selected the Development OTP-in-logs/echo
/// bridge is disabled — the OTP must never be logged once real SMS ships.
/// </summary>
public sealed class SmsOptions
{
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c>.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Gateway API key / token (secret — user-secrets or environment, never committed).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>The registered sender line (used by <c>SendAsync</c> free-form messages and non-template sends).</summary>
public string SenderLine { get; set; } = string.Empty;
/// <summary>Override the gateway base URL (defaults to the provider's public API host).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs).</summary>
public string OtpTemplate { get; set; } = string.Empty;
}
/// <summary>
@@ -60,6 +146,19 @@ public sealed class ReviewModerationOptions
/// </summary>
public sealed class BankTransferOptions
{
/// <summary><c>mock</c> (default) | <c>jibit</c> | <c>vandar</c> | <c>sadad</c> — the payout transferor.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Transferor API base URL.</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>Transferor API key / bearer token (secret).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>The platform's registered <b>source settlement account</b> the batch debits (IBAN/account id the
/// transferor recognises). Every PAYA/SATNA transfer originates here.</summary>
public string SourceSettlementAccount { get; set; } = string.Empty;
/// <summary>When true, every payout instruction is rejected so the whole-batch-failure path is testable.</summary>
public bool ForceFailure { get; set; }
@@ -86,6 +185,19 @@ public sealed class CurrencyOptions
/// </summary>
public sealed class MoadianOptions
{
/// <summary><c>mock</c> (default) | <c>moadian</c> — the real سامانه مودیان submission adapter.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>مودیان API base URL (the tax-authority endpoint).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The platform's مودیان memory/economic id (<c>memoryId</c> / شناسه یکتای حافظه مالیاتی).</summary>
public string MemoryId { get; set; } = string.Empty;
/// <summary>A pre-issued bearer token for the مودیان API (the signing-certificate token exchange is a
/// deploy-time concern; a deployment supplies a current token). Secret.</summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>When true, a submission returns <c>registered</c> + a deterministic fake 22-digit reference.</summary>
public bool ForceRegistered { get; set; }
}
@@ -97,6 +209,19 @@ public sealed class MoadianOptions
/// </summary>
public sealed class BnplOptions
{
/// <summary><c>mock</c> (default) | <c>real</c> — when <c>real</c>, <c>IBnplProviderResolver</c> maps each
/// <c>provider_code</c> to its concrete adapter (SnappPay / Digipay) instead of the one mock.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Per-provider connection facts, keyed by <c>provider_code</c> (<c>snapppay</c>/<c>digipay</c>).
/// Credentials proper (client id/secret) come from the encrypted <c>payment_gateways.config_json</c> in a
/// full deployment; the base URL + non-secret facts can be defaulted here.</summary>
public Dictionary<string, BnplProviderConnection> Providers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The currency the BNPL providers speak on the wire (<c>TOMAN</c> or <c>IRR</c>); conversion to IRR
/// happens only at the adapter boundary via <c>ICurrencyNormalizer</c>. SnappPay/Digipay speak Rial.</summary>
public string WireCurrency { get; set; } = "IRR";
/// <summary>When true, token/revert/update/cancel all fail so the provider-declined paths are testable.</summary>
public bool ForceFailure { get; set; }
@@ -119,6 +244,17 @@ public sealed class BnplOptions
public string NotEligibleMobile { get; set; } = "09120000099";
}
/// <summary>Non-secret connection facts for one BNPL provider (base URL, sandbox flag, merchant handle). The
/// secret client id/secret live in the encrypted <c>payment_gateways.config_json</c>; a real adapter reads both.</summary>
public sealed class BnplProviderConnection
{
public string BaseUrl { get; set; } = string.Empty;
public bool Sandbox { get; set; }
/// <summary>Optional non-secret merchant/terminal identifier the provider expects on requests.</summary>
public string MerchantId { get; set; } = string.Empty;
}
/// <summary>
/// Tunes the b10 money-path mocks (PSP acquirer, تسهیم split, webhook verifier). The real adapters ignore
/// these — production merchant ids / signing keys come from <c>payment_gateways.config_json</c> and secrets,
@@ -126,7 +262,30 @@ public sealed class BnplOptions
/// </summary>
public sealed class PaymentsOptions
{
/// <summary>The platform's own registered IBAN (SHEBA) the mock split credits the commission leg to.</summary>
/// <summary><c>mock</c> (default) | <c>zarinpal</c> | <c>sadad</c> | <c>vandar</c> | <c>jibit</c> — the card
/// acquirer <c>IPaymentProvider</c> + <c>ISettlementSplitProvider</c> + <c>IWebhookVerifier</c> swap together.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Acquirer IPG base URL (the payment-request / verify / refund host).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The acquirer merchant id / terminal (non-secret handle). Production reads it from the encrypted
/// <c>payment_gateways.config_json</c>; this default enables a single-merchant deployment without the DB row.</summary>
public string MerchantId { get; set; } = string.Empty;
/// <summary>Where the acquirer sends the customer back after the hosted payment page (the return deep-link the
/// adapter passes as the callback URL when opening the IPG session).</summary>
public string CallbackUrl { get; set; } = string.Empty;
/// <summary>Per-provider webhook signing secret (HMAC key), keyed by <c>provider_code</c>. The real
/// <c>IWebhookVerifier</c> verifies the raw callback body against this; a provider with no signature falls back
/// to the mandatory server-side <c>verify</c> re-check.</summary>
public Dictionary<string, string> WebhookSigningSecrets { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The header the provider carries its signature in (default <c>X-Signature</c>).</summary>
public string SignatureHeader { get; set; } = "X-Signature";
/// <summary>The platform's own registered IBAN (SHEBA) the split credits the commission leg to.</summary>
public string PlatformSheba { get; set; } = "IR000000000000000000000001";
/// <summary>A callback whose raw body contains this marker is treated as an <b>invalid signature</b> by the
@@ -156,6 +315,9 @@ public sealed class PaymentCaptureOptions
/// </summary>
public sealed class ShahkarOptions
{
/// <summary><c>mock</c> (default) | <c>finnotech</c> — the real شاهکار bridge (shares <c>Seams:Finnotech</c> creds).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
public string SharedSimPhone { get; set; } = "09120000000";
@@ -170,6 +332,9 @@ public sealed class ShahkarOptions
/// </summary>
public sealed class IdentityKycOptions
{
/// <summary><c>mock</c> (default) | <c>finnotech</c> — the real e-KYC bridge (shares <c>Seams:Finnotech</c> creds).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>The designated test national id that fails identity KYC.</summary>
public string FailNationalId { get; set; } = "0000000000";
@@ -186,6 +351,15 @@ public sealed class IdentityKycOptions
/// </summary>
public sealed class GeocodingOptions
{
/// <summary><c>mock</c> (default) | <c>neshan</c> — the real Neshan geocoding adapter.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Neshan API key (secret). The real geocoder sends it as the <c>Api-Key</c> header.</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>Neshan API base URL (defaults to the public host at the adapter).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>When true, every geocode returns null coordinates with low confidence.</summary>
public bool ReturnNullCoordinates { get; set; }
@@ -203,6 +377,9 @@ public sealed class GeocodingOptions
/// </summary>
public sealed class BankOwnershipOptions
{
/// <summary><c>mock</c> (default) | <c>finnotech</c> — the real استعلام شبا bridge (shares <c>Seams:Finnotech</c> creds).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>The designated test IBAN that returns <c>matched_national_id = false</c>.</summary>
public string MismatchIban { get; set; } = "IR000000000000000000000000";
@@ -224,6 +401,31 @@ public sealed class FieldEncryptionOptions
public sealed class ObjectStorageOptions
{
/// <summary><c>local</c> (default) | <c>s3</c> — S3/MinIO/ArvanCloud object storage with presigned PUT/GET.</summary>
public string Provider { get; set; } = SeamProviders.LocalDisk;
/// <summary>Filesystem root the local-disk mock writes blobs under.</summary>
public string RootPath { get; set; } = string.Empty;
/// <summary>S3-compatible endpoint host, e.g. <c>https://s3.ir-thr-at1.arvanstorage.ir</c> or a MinIO URL.</summary>
public string ServiceUrl { get; set; } = string.Empty;
/// <summary>The bucket blobs are stored in.</summary>
public string Bucket { get; set; } = string.Empty;
/// <summary>The S3 region (SigV4 credential scope; MinIO/ArvanCloud commonly use <c>us-east-1</c> or their own).</summary>
public string Region { get; set; } = "us-east-1";
/// <summary>S3 access key id (secret).</summary>
public string AccessKey { get; set; } = string.Empty;
/// <summary>S3 secret access key (secret).</summary>
public string SecretKey { get; set; } = string.Empty;
/// <summary>Use path-style addressing (<c>{endpoint}/{bucket}/{key}</c>) — required by MinIO/ArvanCloud; AWS
/// proper uses virtual-host style. Default true (path-style) since Iranian S3 endpoints expect it.</summary>
public bool UsePathStyle { get; set; } = true;
/// <summary>How long a presigned GET/PUT URL stays valid (seconds).</summary>
public int PresignExpirySeconds { get; set; } = 900;
}
@@ -28,4 +28,16 @@ public static class DevelopmentSeamExtensions
return services;
}
/// <summary>
/// Development/Testing-only re-registration of the real <see cref="MockPaymentCaptureSimulator"/> over the
/// production <see cref="DisabledPaymentCaptureSimulator"/> (refinement-phase-8, 6.4). The <c>bookings/convert</c>
/// simulator path is a dev/test affordance — production converts via the real b10 webhook confirm, not this
/// command. Last registration wins, so callers transparently get the succeeding mock in Dev/Testing.
/// </summary>
public static IServiceCollection AddDevelopmentPaymentCapture(this IServiceCollection services)
{
services.AddSingleton<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
return services;
}
}
@@ -3,96 +3,287 @@ 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 seams (time, PII encryption, cache, object storage, SMS) with their
/// in-memory/local mock implementations. Swapping in a real provider later is a registration change
/// here — callers depend only on the Application contracts. (The real in-app
/// <c>INotificationDispatcher</c> needs the database, so it is registered in the Persistence layer.)
/// 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> (user-secrets/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>();
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
// OTP/SMS delivery rail (backend-phase-2). The mock logs the code; a real gateway client
// (Kavenegar/Ghasedak/SMS.ir) replaces this registration only.
services.AddSingleton<ISmsSender, LoggingSmsSender>();
RegisterObjectStorage(services, seams);
RegisterSms(services, seams);
RegisterTrustRails(services, seams);
RegisterGeocoder(services, seams);
RegisterPaymentRails(services, seams);
RegisterBnpl(services, seams);
RegisterPayoutRail(services, seams);
RegisterMoadian(services, seams);
// استعلام شبا IBAN-owner ↔ national-id inquiry (backend-phase-3). The mock returns a deterministic
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
// 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>();
// Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
// Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian
// e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only —
// no mock behaviour is baked into any handler call site.
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
// Payment-capture trigger (backend-phase-9). The mock returns a deterministic succeeded capture so
// ConvertRequestToBooking is testable now; in b10 the real card capture replaces this registration
// and calls ConvertRequestToBooking directly on a real payment_transactions.succeeded.
services.AddSingleton<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
// Payments money-path seams (backend-phase-10). All four are deterministic mocks; a real card PSP /
// تسهیم split adapter (config-selected per payment_gateways.config_json), per-provider signature
// verifier, and StackExchange.Redis lock swap in by a registration change only — no mock behaviour is
// baked into any handler. The DB uniques/state-machine remain the authoritative money-path backstop.
services.AddSingleton<IPaymentProvider, MockPaymentProvider>();
services.AddSingleton<ISettlementSplitProvider, MockSettlementSplitProvider>();
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered).
services.AddSingleton<IMoadianClient, MockMoadianClient>();
// BNPL seams (backend-phase-12). The deterministic MockBnplProvider drives the full eligible → settled →
// reverted state machine with no network; the resolver selects one impl per provider_code (config-driven,
// never an if(mock) in a handler); ICurrencyNormalizer does Toman↔IRR at the boundary only. A real
// SnappPay/Digipay adapter + real Redis normalizer swap in by a registration change only. IBnplProvider
// is still registered directly for the b11 refund path's bnpl_revert channel.
services.AddSingleton<MockBnplProvider>();
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
// Payout bank rail (backend-phase-13). The deterministic MockBankTransferProvider settles every PAYA/SATNA
// instruction paid with no money movement; a config switch forces whole-batch/single-row failures so the
// partially_failed + retry paths are testable. A real transferor (Jibit/Vandar/Sadad payout) with a
// registered source settlement account + reconciliation callback swaps in by a registration change only —
// the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop.
services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>();
// AI review moderation (backend-phase-14). The mock is a keyword filter / pass-through (clean → human
// flag by default so the publish gate holds; banned word → reject; config toggle auto-approves clean).
// A real text classifier / LLM endpoint swaps in by a registration change only — ModerateReviewCommand
// keeps decision authority + the human override, so the real impl never touches the handler.
// 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>();
// Partner-center licensing (backend-phase-15). eNamad / MoH establishment-permit registries have no
// public B2B API, so the mock returns NeedsManualReview (manual admin approval at MVP; config can force
// auto-approve for tests). A real registry/API client swaps in by a registration change only —
// VerifyPartnerCenter records the decision and is never touched.
services.AddSingleton<ILicenseVerificationService, MockLicenseVerificationService>();
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.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'.");
}
else
{
// b2 log-only mock; the Development OTP-capture decorator is layered on in Program.cs (Development only,
// and only while this mock is selected — a real SMS provider 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 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";
}
}
@@ -53,6 +53,13 @@ internal sealed class InvoiceRepository : BaseAsyncRepository<Invoice>, IInvoice
return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto);
}
public async Task<IReadOnlyList<Invoice>> GetUnregisteredMoadianInvoicesAsync(int max, CancellationToken cancellationToken)
=> await Table
.Where(i => i.MoadianStatus == MoadianStatus.Pending || i.MoadianStatus == MoadianStatus.Submitted)
.OrderBy(i => i.Id)
.Take(max)
.ToListAsync(cancellationToken);
public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
=> base.AddAsync(invoice);
@@ -70,6 +70,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>();
services.AddSingleton<IRecurringJob, NoShowSweepJob>();
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
// refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference.
services.AddSingleton<IRecurringJob, MoadianReconciliationJob>();
services.AddHostedService<RecurringJobSchedulerHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
@@ -0,0 +1,38 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Walks every <c>pending</c>/<c>submitted</c> سامانه مودیان invoice toward its registered 22-digit reference
/// (refinement-phase-8, 6.5) — the async reconciliation the mock <c>IMoadianClient</c> collapses. Runs on a fixed
/// cadence (no new seeded config key → no migration) and is idempotent: a re-submission of an already-registered
/// invoice is a no-op, and مودیان dedups on the invoice number so a re-submit doubles as the status poll. Sends
/// <see cref="ReconcileMoadianInvoicesCommand"/> under the scheduler's per-tick lock.
/// </summary>
internal sealed class MoadianReconciliationJob(ILogger<MoadianReconciliationJob> logger) : IRecurringJob
{
public string Name => "moadian_reconciliation";
// A fixed cadence keeps this off the seeded-config path (no migration); Moadian registration is not
// time-critical, so every few hours is ample.
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(6));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ReconcileMoadianInvoicesCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } reconcile && reconcile.Scanned > 0)
logger.LogInformation(
"مودیان reconciliation scanned {Scanned} invoice(s); {Registered} reached registered",
reconcile.Scanned, reconcile.Registered);
}
}