backend phase 10

This commit is contained in:
hamid
2026-07-06 21:17:00 +03:30
parent 12c7e51c32
commit aae056b4e5
70 changed files with 8124 additions and 73 deletions
@@ -0,0 +1,33 @@
#nullable enable
using System.Collections.Concurrent;
using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// In-process mock <see cref="IDistributedLock"/> — a per-key <see cref="SemaphoreSlim"/> so the money-path
/// code runs the same acquire/release shape it will with real Redis, within a single process. It is
/// deliberately <b>not</b> a correctness guarantee across instances: the DB uniques/state-machine are the
/// authoritative backstop. A real StackExchange.Redis lock (lease/expiry, key <c>booking:{id}:payment</c>)
/// replaces this registration only.
/// </summary>
public sealed class InProcessDistributedLock : IDistributedLock
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new();
public async ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default)
{
var gate = Gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
return new Release(gate);
}
private sealed class Release(SemaphoreSlim gate) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
gate.Release();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IPaymentProvider"/> — no external call. <see cref="InitPaymentAsync"/> returns
/// a stable fake reference derived from the request + idempotency key and a fake redirect URL;
/// <see cref="VerifyAsync"/> instantly succeeds and echoes the expected amount (the server-side re-check always
/// passes in the mock); <see cref="RefundAsync"/> always succeeds (so b11 can call it). A real
/// ZarinPal/Sadad/Vandar/Jibit adapter (acquirer-with-تسهیم, Shaparak reference, sandbox flag from the
/// gateway's encrypted config) replaces this registration only — no mock behaviour is baked into a handler.
/// </summary>
public sealed class MockPaymentProvider : IPaymentProvider
{
public ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
var reference = $"mock-ref-{bookingRequestId}-{idempotencyKey}";
return ValueTask.FromResult(new PaymentInitResult(
RedirectUrl: $"https://mock-psp.local/pay/{reference}",
GatewayReferenceCode: reference));
}
public ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}"));
}
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="ISettlementSplitProvider"/> — records the split intent and reports it
/// settled without moving a Rial (the platform never custodies funds). It accepts any legs whose sum is
/// positive and returns <see cref="SettlementStatus.Settled"/>. A real تسهیم adapter (each beneficiary's
/// registered SHEBA, split-by-ratio config, the ~100,000 IRR min-amount caveat; the provider credits IBANs
/// directly) replaces this registration only.
/// </summary>
public sealed class MockSettlementSplitProvider : ISettlementSplitProvider
{
public ValueTask<SettlementResult> RegisterSplitAsync(long bookingId, IReadOnlyList<SettlementLeg> legs, CancellationToken cancellationToken = default)
{
var status = legs.Count > 0 && legs.Sum(l => l.AmountIrr) > 0
? SettlementStatus.Settled
: SettlementStatus.Failed;
return ValueTask.FromResult(new SettlementResult(status));
}
public ValueTask<SettlementResult> GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new SettlementResult(SettlementStatus.Settled));
}
@@ -0,0 +1,47 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IWebhookVerifier"/>. It treats the signature as valid unless the raw body
/// carries the configured invalid-signature marker (so the "unverified callback mutates nothing" path is
/// testable), and extracts a test <c>external_event_id</c> / <c>event_type</c> / <c>gateway_reference_code</c>
/// from a small JSON body — which lets tests replay a duplicate callback to prove idempotency. A real adapter
/// implements the per-provider HMAC/signature scheme (or the mandatory server-side <c>verify</c> re-check).
/// </summary>
public sealed class MockWebhookVerifier(IOptions<SeamOptions> options) : IWebhookVerifier
{
private readonly PaymentsOptions _options = options.Value.Payments;
public WebhookVerification Verify(string provider, IReadOnlyDictionary<string, string> headers, string rawBody)
{
var signatureValid = !rawBody.Contains(_options.InvalidSignatureMarker, StringComparison.Ordinal);
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)
{
// A body we can't parse yields an empty id/type; the handler stores it and no-ops (nothing to do).
}
var isSuccessEvent = eventType.Contains("succeed", StringComparison.OrdinalIgnoreCase);
return new WebhookVerification(signatureValid, externalEventId, eventType, gatewayReferenceCode, isSuccessEvent);
}
}
@@ -15,6 +15,22 @@ public sealed class SeamOptions
public ShahkarOptions Shahkar { get; set; } = new();
public IdentityKycOptions IdentityKyc { get; set; } = new();
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
public PaymentsOptions Payments { get; set; } = new();
}
/// <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,
/// never from here.
/// </summary>
public sealed class PaymentsOptions
{
/// <summary>The platform's own registered IBAN (SHEBA) the mock 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
/// mock verifier, so the "unverified callback mutates nothing" path is testable.</summary>
public string InvalidSignatureMarker { get; set; } = "INVALID_SIGNATURE";
}
/// <summary>
@@ -1,4 +1,5 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -48,6 +49,15 @@ public static class ServiceCollectionExtension
// 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>();
return services;
}
}