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;
}
}
@@ -3,6 +3,7 @@ using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence.ValueConversion;
@@ -157,5 +158,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(c => c.EmergencyContactName).HasConversion(encrypted);
builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted);
});
// b10 gateway config: provider-selection/failover config (merchant id, terminal/IBAN registration,
// base url, sandbox flag) is encrypted at rest through the same seam and never logged in plaintext.
modelBuilder.Entity<PaymentGateway>(builder =>
{
builder.Property(g => g.ConfigJson).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
/// <summary>
/// <c>ledger_entries</c> is <b>append-only</b>: no soft-delete, no audit-modified columns, no query filter,
/// and (because the entity implements <c>IEntity</c> only, not <c>ITimeModification</c>) the audit interceptor
/// never stamps a modify on it. Corrections are new balancing rows, never edits.
/// </summary>
internal sealed class LedgerEntryConfig : IEntityTypeConfiguration<LedgerEntry>
{
public void Configure(EntityTypeBuilder<LedgerEntry> builder)
{
builder.ToTable("LedgerEntries", "payments");
builder.HasKey(e => e.Id);
builder.Property(e => e.AccountType).HasMaxLength(40).IsRequired();
builder.Property(e => e.Direction).HasMaxLength(6).IsRequired();
builder.Property(e => e.SourceRefType).HasMaxLength(40).IsRequired();
builder.Property(e => e.Memo).HasMaxLength(300);
// Balance reads (b13 payouts): the nurse_payable balance for a nurse; a posting group; a source's legs.
builder.HasIndex(e => new { e.AccountType, e.NurseId });
builder.HasIndex(e => e.TransactionGroupId);
builder.HasIndex(e => new { e.SourceRefType, e.SourceRefId });
builder.HasIndex(e => e.BookingId);
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(e => e.NurseId).IsRequired(false);
builder.HasOne<Booking>().WithMany().HasForeignKey(e => e.BookingId).IsRequired(false);
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
internal sealed class PaymentGatewayConfig : IEntityTypeConfiguration<PaymentGateway>
{
public void Configure(EntityTypeBuilder<PaymentGateway> builder)
{
builder.ToTable("PaymentGateways", "payments");
builder.Property(g => g.ProviderCode).HasMaxLength(50).IsRequired();
builder.Property(g => g.Type).HasMaxLength(20).IsRequired();
builder.Property(g => g.DisplayName).HasMaxLength(100).IsRequired();
// config_json is encrypted at rest (converter wired in ApplicationDbContext); nvarchar(max), no cap.
builder.Property(g => g.ConfigJson).IsRequired();
// Gateway selection reads the active gateway of a type by lowest priority — a covering index.
builder.HasIndex(g => new { g.Type, g.IsActive, g.Priority });
builder.HasQueryFilter(g => g.DeletedAt == null);
}
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
internal sealed class PaymentTransactionConfig : IEntityTypeConfiguration<PaymentTransaction>
{
public void Configure(EntityTypeBuilder<PaymentTransaction> builder)
{
builder.ToTable("PaymentTransactions", "payments");
builder.Property(t => t.Currency).HasMaxLength(3).IsRequired();
builder.Property(t => t.Status).HasMaxLength(20).IsRequired();
builder.Property(t => t.GatewayTransactionId).HasMaxLength(200);
builder.Property(t => t.GatewayReferenceCode).HasMaxLength(200);
builder.Property(t => t.GatewayResponseCode).HasMaxLength(50);
builder.Property(t => t.IpAddress).HasMaxLength(64);
builder.Property(t => t.UserAgent).HasMaxLength(400);
// The two structural idempotency guards (the whole point of the phase). SQL Server/SQLite both honour
// filtered (partial) unique indexes; NULLs sit outside the filter so pending rows don't collide.
builder.HasIndex(t => t.GatewayReferenceCode)
.IsUnique()
.HasFilter("[GatewayReferenceCode] IS NOT NULL");
// At most one capturing transaction per booking — the authoritative anti-double-capture backstop.
builder.HasIndex(t => t.BookingId)
.IsUnique()
.HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL");
// The lookup used by initiate/confirm (attempts for a request/booking by status).
builder.HasIndex(t => new { t.BookingId, t.Status });
builder.HasIndex(t => new { t.BookingRequestId, t.Status });
builder.HasOne<BookingRequest>().WithMany().HasForeignKey(t => t.BookingRequestId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(t => t.BookingId).IsRequired(false);
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(t => t.CustomerId).IsRequired();
builder.HasOne<PaymentGateway>().WithMany().HasForeignKey(t => t.GatewayId).IsRequired();
builder.HasQueryFilter(t => t.DeletedAt == null);
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
internal sealed class PaymentWebhookEventConfig : IEntityTypeConfiguration<PaymentWebhookEvent>
{
public void Configure(EntityTypeBuilder<PaymentWebhookEvent> builder)
{
builder.ToTable("PaymentWebhookEvents", "payments");
builder.Property(e => e.ProviderCode).HasMaxLength(50).IsRequired();
builder.Property(e => e.ExternalEventId).HasMaxLength(200).IsRequired();
builder.Property(e => e.EventType).HasMaxLength(80).IsRequired();
builder.Property(e => e.ProcessingStatus).HasMaxLength(20).IsRequired();
// payload_json is nvarchar(max) (raw callback); no length cap.
builder.Property(e => e.PayloadJson).IsRequired();
// THE idempotency key — a duplicate provider event can never insert twice, so a replay can never
// double-confirm/double-count. No soft-delete: this store is append/upsert-only.
builder.HasIndex(e => new { e.ProviderCode, e.ExternalEventId }).IsUnique();
}
}
@@ -0,0 +1,265 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class PaymentsCoreLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "payments");
migrationBuilder.CreateTable(
name: "LedgerEntries",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TransactionGroupId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AccountType = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: true),
Direction = table.Column<string>(type: "nvarchar(6)", maxLength: 6, nullable: false),
AmountIrr = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
SourceRefType = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
SourceRefId = table.Column<long>(type: "bigint", nullable: false),
Memo = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LedgerEntries", x => x.Id);
table.ForeignKey(
name: "FK_LedgerEntries_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LedgerEntries_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "PaymentGateways",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Type = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Priority = table.Column<int>(type: "int", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PaymentGateways", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PaymentWebhookEvents",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
ExternalEventId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
EventType = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
SignatureValid = table.Column<bool>(type: "bit", nullable: false),
PayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
ProcessingStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
RelatedPaymentTransactionId = table.Column<long>(type: "bigint", nullable: true),
ReceivedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PaymentWebhookEvents", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PaymentTransactions",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingRequestId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
GatewayId = table.Column<long>(type: "bigint", nullable: false),
Amount = table.Column<long>(type: "bigint", nullable: false),
Currency = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
GatewayTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
GatewayReferenceCode = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
GatewayResponseCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
GatewayResponseJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsInstallment = table.Column<bool>(type: "bit", nullable: false),
IpAddress = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PaymentTransactions", x => x.Id);
table.ForeignKey(
name: "FK_PaymentTransactions_BookingRequests_BookingRequestId",
column: x => x.BookingRequestId,
principalSchema: "booking",
principalTable: "BookingRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PaymentTransactions_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id");
table.ForeignKey(
name: "FK_PaymentTransactions_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PaymentTransactions_PaymentGateways_GatewayId",
column: x => x.GatewayId,
principalSchema: "payments",
principalTable: "PaymentGateways",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_LedgerEntries_AccountType_NurseId",
schema: "payments",
table: "LedgerEntries",
columns: new[] { "AccountType", "NurseId" });
migrationBuilder.CreateIndex(
name: "IX_LedgerEntries_BookingId",
schema: "payments",
table: "LedgerEntries",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_LedgerEntries_NurseId",
schema: "payments",
table: "LedgerEntries",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "IX_LedgerEntries_SourceRefType_SourceRefId",
schema: "payments",
table: "LedgerEntries",
columns: new[] { "SourceRefType", "SourceRefId" });
migrationBuilder.CreateIndex(
name: "IX_LedgerEntries_TransactionGroupId",
schema: "payments",
table: "LedgerEntries",
column: "TransactionGroupId");
migrationBuilder.CreateIndex(
name: "IX_PaymentGateways_Type_IsActive_Priority",
schema: "payments",
table: "PaymentGateways",
columns: new[] { "Type", "IsActive", "Priority" });
migrationBuilder.CreateIndex(
name: "IX_PaymentTransactions_BookingId",
schema: "payments",
table: "PaymentTransactions",
column: "BookingId",
unique: true,
filter: "[Status] = 'succeeded' AND [BookingId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PaymentTransactions_BookingId_Status",
schema: "payments",
table: "PaymentTransactions",
columns: new[] { "BookingId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_PaymentTransactions_BookingRequestId_Status",
schema: "payments",
table: "PaymentTransactions",
columns: new[] { "BookingRequestId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_PaymentTransactions_CustomerId",
schema: "payments",
table: "PaymentTransactions",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_PaymentTransactions_GatewayId",
schema: "payments",
table: "PaymentTransactions",
column: "GatewayId");
migrationBuilder.CreateIndex(
name: "IX_PaymentTransactions_GatewayReferenceCode",
schema: "payments",
table: "PaymentTransactions",
column: "GatewayReferenceCode",
unique: true,
filter: "[GatewayReferenceCode] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PaymentWebhookEvents_ProviderCode_ExternalEventId",
schema: "payments",
table: "PaymentWebhookEvents",
columns: new[] { "ProviderCode", "ExternalEventId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LedgerEntries",
schema: "payments");
migrationBuilder.DropTable(
name: "PaymentTransactions",
schema: "payments");
migrationBuilder.DropTable(
name: "PaymentWebhookEvents",
schema: "payments");
migrationBuilder.DropTable(
name: "PaymentGateways",
schema: "payments");
}
}
}
@@ -2675,6 +2675,280 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<long>("AmountIrr")
.HasColumnType("bigint");
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Direction")
.IsRequired()
.HasMaxLength(6)
.HasColumnType("nvarchar(6)");
b.Property<string>("Memo")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<long?>("NurseId")
.HasColumnType("bigint");
b.Property<long>("SourceRefId")
.HasColumnType("bigint");
b.Property<string>("SourceRefType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("TransactionGroupId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("NurseId");
b.HasIndex("TransactionGroupId");
b.HasIndex("AccountType", "NurseId");
b.HasIndex("SourceRefType", "SourceRefId");
b.ToTable("LedgerEntries", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ConfigJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<string>("ProviderCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("Type", "IsActive", "Priority");
b.ToTable("PaymentGateways", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<long>("BookingRequestId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("GatewayId")
.HasColumnType("bigint");
b.Property<string>("GatewayReferenceCode")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("GatewayResponseCode")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("GatewayResponseJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("GatewayTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsInstallment")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("UserAgent")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique()
.HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL");
b.HasIndex("CustomerId");
b.HasIndex("GatewayId");
b.HasIndex("GatewayReferenceCode")
.IsUnique()
.HasFilter("[GatewayReferenceCode] IS NOT NULL");
b.HasIndex("BookingId", "Status");
b.HasIndex("BookingRequestId", "Status");
b.ToTable("PaymentTransactions", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("ExternalEventId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("ProcessingStatus")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("ProviderCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("datetime2");
b.Property<long?>("RelatedPaymentTransactionId")
.HasColumnType("bigint");
b.Property<bool>("SignatureValid")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ProviderCode", "ExternalEventId")
.IsUnique();
b.ToTable("PaymentWebhookEvents", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.Property<long>("Id")
@@ -3921,6 +4195,42 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId");
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId");
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
.WithMany()
.HasForeignKey("BookingRequestId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null)
.WithMany()
.HasForeignKey("GatewayId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
@@ -2,6 +2,7 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
@@ -24,6 +25,12 @@ internal sealed class BookingRepository : BaseAsyncRepository<Booking>, IBooking
.Select(b => (long?)b.Id)
.FirstOrDefaultAsync(cancellationToken);
public Task<BookingLedgerAmounts?> GetLedgerAmountsAsync(long id, CancellationToken cancellationToken)
=> TableNoTracking
.Where(b => b.Id == id)
.Select(b => new BookingLedgerAmounts(b.NurseId, b.GrossPriceIrr, b.BalinyaarCommissionIrr, b.NursePayoutAmount))
.FirstOrDefaultAsync(cancellationToken);
public async Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
{
var row = await (
@@ -3,6 +3,7 @@ using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
@@ -222,6 +223,17 @@ internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequ
r.CustomerAddress.Longitude)))
.FirstOrDefaultAsync(cancellationToken);
public Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.Id == id)
.Select(r => new BookingPaymentContext(
r.Id,
r.CustomerId,
r.Status,
r.PaymentDeadlineAt,
r.Variant.Price * (r.Variant.SessionCount != null ? r.Variant.SessionCount.Value : 1)))
.FirstOrDefaultAsync(cancellationToken);
// Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency
// (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation
// time anyway, and DateTimeOffset is not sortable on the SQLite test provider.
@@ -22,6 +22,7 @@ public class UnitOfWork : IUnitOfWork
public IBookingRequestRepository BookingRequestRepository { get; }
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public IPaymentRepository PaymentRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -42,6 +43,7 @@ public class UnitOfWork : IUnitOfWork
BookingRequestRepository = new BookingRequestRepository(_db);
BookingRepository = new BookingRepository(_db);
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
PaymentRepository = new PaymentRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,67 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Domain.Entities.Payments;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PaymentRepository : BaseAsyncRepository<PaymentTransaction>, IPaymentRepository
{
public PaymentRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<long?> GetActiveGatewayIdAsync(string type, CancellationToken cancellationToken)
=> DbContext.Set<PaymentGateway>().AsNoTracking()
.Where(g => g.Type == type && g.IsActive)
.OrderBy(g => g.Priority)
.Select(g => (long?)g.Id)
.FirstOrDefaultAsync(cancellationToken);
public Task AddGatewayAsync(PaymentGateway gateway, CancellationToken cancellationToken)
=> DbContext.Set<PaymentGateway>().AddAsync(gateway, cancellationToken).AsTask();
public Task AddTransactionAsync(PaymentTransaction transaction, CancellationToken cancellationToken)
=> base.AddAsync(transaction);
public Task<bool> HasSucceededTransactionForRequestAsync(long bookingRequestId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(
t => t.BookingRequestId == bookingRequestId && t.Status == PaymentTransactionStatus.Succeeded,
cancellationToken);
public Task<PaymentTransaction?> GetTrackedTransactionByReferenceAsync(string gatewayReferenceCode, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.GatewayReferenceCode == gatewayReferenceCode, cancellationToken);
public Task<PaymentTransaction?> GetTrackedTransactionByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
public Task<PaymentWebhookEvent?> GetWebhookEventByKeyAsync(string providerCode, string externalEventId, CancellationToken cancellationToken)
=> DbContext.Set<PaymentWebhookEvent>().AsNoTracking()
.FirstOrDefaultAsync(e => e.ProviderCode == providerCode && e.ExternalEventId == externalEventId, cancellationToken);
public Task AddWebhookEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
=> DbContext.Set<PaymentWebhookEvent>().AddAsync(webhookEvent, cancellationToken).AsTask();
public Task<bool> LedgerGroupExistsForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AsNoTracking().AnyAsync(
e => e.SourceRefType == LedgerSourceRefType.PaymentTransaction && e.SourceRefId == paymentTransactionId,
cancellationToken);
public Task AddLedgerEntriesAsync(IEnumerable<LedgerEntry> entries, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AddRangeAsync(entries, cancellationToken);
public async Task<long> GetNursePayableBalanceAsync(long nurseId, CancellationToken cancellationToken)
{
// Signed sum over the append-only ledger — credit adds, debit subtracts. No cached wallet column.
var query = DbContext.Set<LedgerEntry>().AsNoTracking()
.Where(e => e.AccountType == LedgerAccountType.NursePayable && e.NurseId == nurseId);
var credits = await query.Where(e => e.Direction == LedgerDirection.Credit)
.SumAsync(e => (long?)e.AmountIrr, cancellationToken) ?? 0;
var debits = await query.Where(e => e.Direction == LedgerDirection.Debit)
.SumAsync(e => (long?)e.AmountIrr, cancellationToken) ?? 0;
return credits - debits;
}
}
@@ -7,6 +7,7 @@ using Baya.Application.Contracts.Notifications;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Domain.Entities.Payments;
using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Infrastructure.Persistence.Services.Analytics;
@@ -83,4 +84,31 @@ public static class ServiceCollectionExtensions
await context.Database.MigrateAsync();
}
/// <summary>
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
/// must go through the DbContext, not <c>HasData</c>). Real merchant credentials come from user-secrets /
/// environment per deployment — this sandbox row is non-secret and only enables the local/dev flow.
/// </summary>
public static async Task SeedPaymentGatewaysAsync(this WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (await context.Set<PaymentGateway>().AnyAsync(g => g.Type == PaymentGatewayType.Standard))
return;
context.Set<PaymentGateway>().Add(new PaymentGateway
{
ProviderCode = "zarinpal",
Type = PaymentGatewayType.Standard,
DisplayName = "ZarinPal (sandbox)",
ConfigJson = "{\"merchantId\":\"00000000-0000-0000-0000-000000000000\",\"baseUrl\":\"https://sandbox.zarinpal.com\",\"sandbox\":true}",
IsActive = true,
Priority = 0
});
await context.SaveChangesAsync();
}
}