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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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();
|
||||
}
|
||||
}
|
||||
+4502
File diff suppressed because it is too large
Load Diff
+265
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
@@ -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)
|
||||
|
||||
+7
@@ -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 (
|
||||
|
||||
+12
@@ -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.
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user