backend phase 10
This commit is contained in:
+33
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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}"));
|
||||
}
|
||||
+25
@@ -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));
|
||||
}
|
||||
+47
@@ -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>
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user