From 0ed04211bf6df1058d3a54d82d52b58936458c73 Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Tue, 2 Jun 2026 23:44:45 +0200 Subject: [PATCH] Added payment gateway ledger service to payments feature --- .../Common/Fixture.cs | 4 +- ...eCharms.Features.MidrandBooks.Tests.csproj | 1 + .../PayfastServiceFeatureTests.cs | 113 ++ .../appsettings.json | 12 + .../Extensions/Mappers.cs | 21 +- .../LiteCharms.Features.MidrandBooks.csproj | 4 + .../Payments/Entities/PaymentGatewayLedger.cs | 11 + .../PaymentGatewayLedgerConfiguration.cs | 30 + .../Payments/Entities/PaymentLedger.cs | 2 - .../Entities/PaymentLedgerConfiguration.cs | 9 +- ...PaymentConfirmationReceivedEventHandler.cs | 188 ++- ...PayfastPaymentConfirmationReceivedEvent.cs | 13 +- .../Payments/Models/PayfastWebhookPayload.cs | 59 + .../Payments/Models/PaymentGatewayLedger.cs | 26 + .../Payments/Models/PaymentLedger.cs | 4 +- .../Payments/Models/Records.cs | 21 + .../Payments/PayfastService.cs | 171 +++ .../Payments/PaymentService.cs | 7 +- .../Postgres/MidrandBooksDbContext.cs | 2 + ...2421_AddedPaymentGatewayLedger.Designer.cs | 1291 ++++++++++++++++ ...0260602202421_AddedPaymentGatewayLedger.cs | 108 ++ ...aymentIdToPaymentGatewayLedger.Designer.cs | 1292 +++++++++++++++++ ...dPayfastPaymentIdToPaymentGatewayLedger.cs | 36 + .../MidrandBooksDbContextModelSnapshot.cs | 91 +- .../HashServiceFeatureTests.cs | 25 - LiteCharms.Features/Enums.cs | 1 + LiteCharms.Features/Extensions/Hash.cs | 81 +- LiteCharms.Features/Hasher/HashService.cs | 44 +- .../LiteCharms.Features.csproj | 1 + .../Models/PayfastWebhookPayload.cs | 8 - 30 files changed, 3420 insertions(+), 256 deletions(-) create mode 100644 LiteCharms.Features.MidrandBooks.Tests/PayfastServiceFeatureTests.cs create mode 100644 LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedger.cs create mode 100644 LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedgerConfiguration.cs create mode 100644 LiteCharms.Features.MidrandBooks/Payments/Models/PayfastWebhookPayload.cs create mode 100644 LiteCharms.Features.MidrandBooks/Payments/Models/PaymentGatewayLedger.cs create mode 100644 LiteCharms.Features.MidrandBooks/Payments/PayfastService.cs create mode 100644 LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.Designer.cs create mode 100644 LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.cs create mode 100644 LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.Designer.cs create mode 100644 LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.cs delete mode 100644 LiteCharms.Features/Models/PayfastWebhookPayload.cs diff --git a/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs b/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs index dae42e0..0732e5b 100644 --- a/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs +++ b/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs @@ -24,7 +24,8 @@ public class Fixture : IDisposable .AddEnvironmentVariables() .Build(); - Services = new ServiceCollection() + Services = new ServiceCollection() + .AddHttpClient() .AddMediator() .AddLogging() .AddEmailServiceBus() @@ -33,6 +34,7 @@ public class Fixture : IDisposable .AddEmailServices(Configuration) .AddSingleton(Configuration) .AddShopServices() + .AddHashServices(Configuration) .BuildServiceProvider(); Mediator = Services.GetRequiredService(); diff --git a/LiteCharms.Features.MidrandBooks.Tests/LiteCharms.Features.MidrandBooks.Tests.csproj b/LiteCharms.Features.MidrandBooks.Tests/LiteCharms.Features.MidrandBooks.Tests.csproj index 3b0e796..174bbd1 100644 --- a/LiteCharms.Features.MidrandBooks.Tests/LiteCharms.Features.MidrandBooks.Tests.csproj +++ b/LiteCharms.Features.MidrandBooks.Tests/LiteCharms.Features.MidrandBooks.Tests.csproj @@ -39,6 +39,7 @@ + diff --git a/LiteCharms.Features.MidrandBooks.Tests/PayfastServiceFeatureTests.cs b/LiteCharms.Features.MidrandBooks.Tests/PayfastServiceFeatureTests.cs new file mode 100644 index 0000000..66da983 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks.Tests/PayfastServiceFeatureTests.cs @@ -0,0 +1,113 @@ +using LiteCharms.Features.MidrandBooks.Payments; +using LiteCharms.Features.MidrandBooks.Payments.Models; +using LiteCharms.Features.MidrandBooks.Tests.Common; + +namespace LiteCharms.Features.MidrandBooks.Tests; + +public sealed class PayfastServiceFeatureTests(Fixture fixture) : IClassFixture +{ + private readonly PayfastService payfastService = fixture.Services.GetRequiredService(); + + [IntegrationFact] + public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithGatewayLedgerId() + { + var request = new CreateGatewayLedgerEntry + { + OrderId = 1, + PaymentId = 1, + MerchantPaymentId = "M_REF_TEST_99", + PayfastPaymentId = "PF_SYS_ID_10023", + CustomerEmail = "buyer@litecharms.co.za", + AmountGross = 350.00m, + AmountFee = 12.50m, + AmountNet = 337.50m, + PaymentStatus = "COMPLETE" + }; + + var result = await payfastService.WriteLedgerEntryAsync(request, fixture.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.True(result.Value > 0); + } + + [IntegrationFact] + public async Task ValidateReferrerIpAsync_WithValidPayfastHostIp_ShouldReturnTrue() + { + var addresses = await Dns.GetHostAddressesAsync("sandbox.payfast.co.za", fixture.CancellationToken); + + string liveTargetIp = addresses.First().ToString(); + + var result = await payfastService.ValidateReferrerIpAsync(liveTargetIp, fixture.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.True(result.Value); + } + + [IntegrationFact] + public async Task ValidateReferrerIpAsync_WithUntrustedIp_ShouldReturnFalse() + { + string rogueIp = "8.8.8.8"; + + var result = await payfastService.ValidateReferrerIpAsync(rogueIp, fixture.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.False(result.Value); + } + + [IntegrationFact] + public void ValidatePaymentAmount_WhenWithinAllowableDelta_ShouldReturnTrue() + { + decimal systemExpectedTotal = 199.99m; + string gatewayClearedGross = "200.00"; // Variance is exactly R0.01 + + var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross); + + Assert.True(result.IsSuccess); + Assert.True(result.Value); + } + + [IntegrationFact] + public void ValidatePaymentAmount_WhenVarianceBreachesDeltaBounds_ShouldReturnFalse() + { + decimal systemExpectedTotal = 199.99m; + string gatewayClearedGross = "150.00"; + + var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross); + + Assert.True(result.IsSuccess); + Assert.False(result.Value); + } + + [IntegrationFact] + public async Task ValidateServerConfirmationAsync_WithUnrecognizedPayload_ShouldReturnFalseFromCentralGateway() + { + // Arrange - Execute against actual Payfast servers using raw mock parameters. + // The server handshake will return 200 OK with string payload 'INVALID' + string arbitraryParameters = "merchant_id=10000000&payment_status=COMPLETE"; + + var result = await payfastService.ValidateServerConfirmationAsync(arbitraryParameters, isSandbox: true, fixture.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.False(result.Value); // Handshake data rejected as fraudulent/unrecognized + } + + [IntegrationFact] + public void GenerateSignature_WithStandardTelemetryData_ShouldSucceedAndHashString() + { + var telemetryPayload = new Dictionary + { + { "merchant_id", "10049307" }, + { "merchant_key", "ju6navn0jcbf0" }, + { "amount_gross", "250.00" }, + { "item_name", "Midrand School Textbook Variant A" } + }; + + string passphrase = "oauth_test_signature_pass"; + + var result = PayfastService.GenerateSignature(telemetryPayload, passphrase); + + Assert.True(result.IsSuccess); + Assert.False(string.IsNullOrWhiteSpace(result.Value)); + Assert.Equal(32, result.Value.Length); // MD5 outputs hex representations totaling 32 characters + } +} \ No newline at end of file diff --git a/LiteCharms.Features.MidrandBooks.Tests/appsettings.json b/LiteCharms.Features.MidrandBooks.Tests/appsettings.json index 7f9a6b8..b6f6ba7 100644 --- a/LiteCharms.Features.MidrandBooks.Tests/appsettings.json +++ b/LiteCharms.Features.MidrandBooks.Tests/appsettings.json @@ -1,4 +1,16 @@ { + "ValidPayfastHosts": [ + "www.payfast.co.za", + "sandbox.payfast.co.za", + "w1w.payfast.co.za", + "w2w.payfast.co.za", + "ips.payfast.co.za", + "api.payfast.co.za", + "payment.payfast.io" + ], + "HasherSettings": { + "MinHashLength": 11 + }, "BookshopS3Settings": { "ServiceUrl": "http://192.168.1.177:30900", "Region": "garage", diff --git a/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs b/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs index 118dc79..467d096 100644 --- a/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs +++ b/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs @@ -6,12 +6,26 @@ using LiteCharms.Features.MidrandBooks.Orders.Models; using LiteCharms.Features.MidrandBooks.Pages.Models; using LiteCharms.Features.MidrandBooks.Payments.Models; using LiteCharms.Features.MidrandBooks.Products.Models; -using Microsoft.CodeAnalysis.CSharp.Syntax; namespace LiteCharms.Features.MidrandBooks.Extensions; public static class Mappers { + public static PaymentGatewayLedger ToModel(this Payments.Entities.PaymentGatewayLedger entity) => new() + { + Id = entity.Id, + CreatedAt = entity.CreatedAt, + CustomerEmail = entity.CustomerEmail, + OrderId = entity.OrderId, + PaymentId = entity.PaymentId, + MerchantPaymentId = entity.MerchantPaymentId, + PayfastPaymentId = entity.PayfastPaymentId, + PaymentStatus = entity.PaymentStatus, + AmountGross = entity.AmountGross, + AmountFee = entity.AmountFee, + AmountNet = entity.AmountNet + }; + public static Refund ToModel(this Payments.Entities.Refund entity) => new() { CreatedAt = entity.CreatedAt, @@ -30,10 +44,9 @@ public static class Mappers CreatedAt = entity.CreatedAt, CustomerId = entity.CustomerId, OrderId = entity.OrderId, - PaymentGatewayId = entity.PaymentGatewayId, - PaymentGatewayReference = entity.PaymentGatewayReference, PaymentId = entity.PaymentId, - Status = entity.Status, + Status = entity.Status, + MerchantPaymentId = entity.MerchantPaymentId, }; public static PaymentGateway ToModel(this Payments.Entities.PaymentGateway entity) => new() diff --git a/LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj b/LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj index e2e58d3..1206d52 100644 --- a/LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj +++ b/LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj @@ -148,6 +148,10 @@ + + + + diff --git a/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedger.cs b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedger.cs new file mode 100644 index 0000000..6ed1fe6 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedger.cs @@ -0,0 +1,11 @@ +using LiteCharms.Features.MidrandBooks.Orders.Entities; + +namespace LiteCharms.Features.MidrandBooks.Payments.Entities; + +[EntityTypeConfiguration] +public class PaymentGatewayLedger : Models.PaymentGatewayLedger +{ + public virtual Order? Order { get; set; } + + public virtual Payment? Payment { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedgerConfiguration.cs b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedgerConfiguration.cs new file mode 100644 index 0000000..f95b256 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentGatewayLedgerConfiguration.cs @@ -0,0 +1,30 @@ +namespace LiteCharms.Features.MidrandBooks.Payments.Entities; + +public sealed class PaymentGatewayLedgerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("GatewayLedger"); + + builder.HasKey(f => f.Id); + builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); + builder.Property(f => f.OrderId).IsRequired(); + builder.Property(f => f.PaymentId).IsRequired(); + builder.Property(f => f.PayfastPaymentId).IsRequired(); + builder.Property(f => f.MerchantPaymentId).IsRequired(); + builder.Property(f => f.AmountGross).IsRequired().HasPrecision(18, 2); + builder.Property(f => f.AmountFee).IsRequired().HasPrecision(18, 2); + builder.Property(f => f.AmountNet).IsRequired().HasPrecision(18, 2); + builder.Property(f => f.CustomerEmail).IsRequired(false); + + builder.HasOne(f => f.Order) + .WithMany() + .HasForeignKey(f => f.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(f => f.Payment) + .WithMany() + .HasForeignKey(f => f.PaymentId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedger.cs b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedger.cs index 3bba78b..acec0ea 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedger.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedger.cs @@ -11,6 +11,4 @@ public class PaymentLedger : Models.PaymentLedger public virtual Order? Order { get; set; } public virtual Customer? Customer { get; set; } - - public virtual PaymentGateway? Gateway { get; set; } } diff --git a/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedgerConfiguration.cs b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedgerConfiguration.cs index 0e51655..c0add81 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedgerConfiguration.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/Entities/PaymentLedgerConfiguration.cs @@ -9,8 +9,7 @@ public sealed class PaymentLedgerConfiguration : IEntityTypeConfiguration f.Id); builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); builder.Property(f => f.Status).IsRequired(); - builder.Property(f => f.PaymentGatewayReference).IsRequired(false); - builder.Property(f => f.PaymentGatewayId).IsRequired(false); + builder.Property(f => f.MerchantPaymentId).IsRequired(false); builder.Property(f => f.OrderId).IsRequired(); builder.Property(f => f.CustomerId).IsRequired(); builder.Property(f => f.PaymentId).IsRequired(); @@ -31,11 +30,5 @@ public sealed class PaymentLedgerConfiguration : IEntityTypeConfiguration f.CustomerId); - - builder.HasOne(f => f.Gateway) - .WithMany() - .IsRequired(false) - .HasForeignKey(f => f.PaymentGatewayId) - .OnDelete(DeleteBehavior.Cascade); } } diff --git a/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs b/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs index 66ba389..fb785f8 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs @@ -1,86 +1,158 @@ using LiteCharms.Features.Hasher; +using LiteCharms.Features.Hasher.Configuration; using LiteCharms.Features.MidrandBooks.Orders; +using LiteCharms.Features.MidrandBooks.Payments.Models; namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers; -public sealed class PayfastPaymentConfirmationReceivedEventHandler(IServiceProvider services, ILogger logger) : +public sealed class PayfastPaymentConfirmationReceivedEventHandler(IServiceProvider services, IOptions hasherOptions, ILogger logger) : INotificationHandler { + private readonly HasherSettings hasherSettings = hasherOptions.Value; + public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); + var hashService = scope.ServiceProvider.GetRequiredService(); + var orderService = scope.ServiceProvider.GetRequiredService(); + var paymentService = scope.ServiceProvider.GetRequiredService(); + var payfastService = scope.ServiceProvider.GetRequiredService(); - PaymentService paymentService = scope.ServiceProvider.GetRequiredService(); - OrderService orderService = scope.ServiceProvider.GetRequiredService(); - HashService hashService = scope.ServiceProvider.GetRequiredService(); + var payload = notification.Payload ?? throw new Exception("Payload metadata context context is null."); - var hashResult = hashService.DecodeLongIdHash(notification.Payload?.MPaymentId!); - if (hashResult.IsFailed) + var dict = payload.ToParamDictionary(); + var localSignature = PayfastService.GenerateSignature(dict, hasherSettings.PayfastPassphrase); + + if(localSignature.IsFailed) + throw new Exception("Failed to generate local signature for incoming webhook payload."); + + if (!string.Equals(localSignature.Value, payload.Signature, StringComparison.OrdinalIgnoreCase)) { - logger.LogError("Failed to decode payment ID hash: {Hash}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", hashResult.Errors.Select(e => e.Message))); - throw new Exception($"Failed to decode payment ID hash: {notification.Payload?.MPaymentId}."); - } + logger.LogCritical("Incoming webhook signature verification failed. Possible payload tampering."); - var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken); - if (orderResult.IsFailed) - { - logger.LogError("Failed to retrieve order for payment ID: {PaymentId}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", orderResult.Errors.Select(e => e.Message))); - throw new Exception($"Failed to retrieve order for payment ID: {notification.Payload?.MPaymentId}."); - } - - var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.CustomerId, cancellationToken); - if (paymentResult.IsFailed) - { - logger.LogError("Failed to retrieve payment for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", paymentResult.Errors.Select(e => e.Message))); - throw new Exception($"Failed to retrieve payment for order ID: {orderResult.Value.Id}."); - } - - var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, 1, cancellationToken); - - if (isAlreadyProcessed.IsFailed) - { - logger.LogError("Failed to check existing ledger entry for order ID: {OrderId} and payment ID: {PaymentId}. Errors: {Errors}", orderResult.Value.Id, paymentResult.Value.Id, string.Join(", ", isAlreadyProcessed.Errors.Select(e => e.Message))); - throw new Exception($"Failed to check existing ledger entry for order ID: {orderResult.Value.Id} and payment ID: {paymentResult.Value.Id}."); - } - - if (isAlreadyProcessed.Value) - { - logger.LogInformation("Payment confirmation for payment ID: {PaymentId} has already been processed. Skipping.", notification.Payload?.MPaymentId); return; } - var ledgerResult = await paymentService.WriteLedgerEntryAsync(new Models.CreateLedgerEntry + var hashResult = hashService.DecodeLongIdHash(payload.MerchantPaymentId!); + + if (hashResult.IsFailed) throw new Exception("Failed to decode application tracking hash key identifier."); + + var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken); + + if (orderResult.IsFailed) throw new Exception("Target system order entity context cannot be traced."); + + var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.Id, cancellationToken); + + if (paymentResult.IsFailed) throw new Exception("Target payment ledger entity cannot be resolved."); + + decimal.TryParse(payload.AmountGross, CultureInfo.InvariantCulture, out var gross); + decimal.TryParse(payload.AmountFee, CultureInfo.InvariantCulture, out var fee); + decimal.TryParse(payload.AmountNet, CultureInfo.InvariantCulture, out var net); + string status = payload.PaymentStatus ?? "UNKNOWN"; + + var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, cancellationToken); + + if (isAlreadyProcessed.Value) + { + logger.LogWarning("Webhook reference token '{Ref}' already verified. Skipping validation routines.", payload.MerchantPaymentId); + + return; + } + + if (notification.PerformBackgroundChecks) + { + var isHostValid = await payfastService.ValidateReferrerIpAsync(notification.RemoteIpAddress!, cancellationToken); + + if (isHostValid.IsFailed) + throw new Exception("Security validation exception: Webhook packet source address failed cluster validation checks."); + + if (!isHostValid.Value) + throw new Exception("Security validation exception: Webhook packet source address failed cluster validation checks."); + + var isAmountValid = payfastService.ValidatePaymentAmount(orderResult.Value.Total, payload.AmountGross); + + if (!isAmountValid.Value) + throw new Exception("Security validation exception: Transaction cost variance bounds breached."); + + var paramList = new List(); + + foreach (var kvp in dict) + { + if (!string.IsNullOrEmpty(kvp.Value)) + { + string encoded = HttpUtility.UrlEncode(kvp.Value.Trim()); + + string safeValue = PayfastService.PercentEncodingRegex.Replace(encoded, m => m.Value.ToLowerInvariant()); + paramList.Add($"{kvp.Key}={safeValue}"); + } + } + + string rawParamString = string.Join("&", paramList); + + var serverConfirmation = await payfastService.ValidateServerConfirmationAsync(rawParamString, isSandbox: true, cancellationToken); + + if (serverConfirmation.IsFailed) + throw new Exception("Security validation exception: Payfast central handshake server rejected payload legitimacy."); + } + + await payfastService.WriteLedgerEntryAsync(new CreateGatewayLedgerEntry { - CustomerId = orderResult.Value.CustomerId, OrderId = orderResult.Value.Id, PaymentId = paymentResult.Value.Id, - Status = LedgerStatuses.Received, - PaymentGatewayId = 1, - PaymentGatewayReference = notification.CorrelationId, + MerchantPaymentId = payload.MerchantPaymentId!, + PayfastPaymentId = payload.PaymentId, + CustomerEmail = payload.EmailAddress, + AmountFee = fee, + AmountGross = gross, + AmountNet = net, + PaymentStatus = status, }, cancellationToken); - if (ledgerResult.IsFailed) + if (status.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase)) { - logger.LogError("Failed to write ledger entry for payment ID: {PaymentId}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", ledgerResult.Errors.Select(e => e.Message))); - throw new Exception($"Failed to write ledger entry for payment ID: {notification.Payload?.MPaymentId}."); - } + var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry + { + OrderId = orderResult.Value.Id, + PaymentId = paymentResult.Value.Id, + PaymentGatewayReference = payload.PaymentId!, + Status = LedgerStatuses.Completed, + CustomerId = orderResult.Value.CustomerId, + }, cancellationToken); - var paymentCompletedResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken); - if (paymentCompletedResult.IsFailed) + if (ledgerWriteResult.IsFailed) + throw new Exception("Failed to write ledger entry for payment confirmation."); + + var completePaymentResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken); + + if (completePaymentResult.IsFailed) + throw new Exception("Failed to update payment status to 'Paid' for payment confirmation."); + + var updateOrderResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken); + + if (updateOrderResult.IsFailed) + throw new Exception("Failed to update order status to 'Completed' for payment confirmation."); + + logger.LogInformation("Order payment verified secure and cleared successfully."); + } + else { - logger.LogError("Failed to complete payment for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", paymentCompletedResult.Errors.Select(e => e.Message))); - throw new Exception($"Failed to complete payment for order ID: {orderResult.Value.Id}."); + LedgerStatuses ledgerStatus; + + if (status.Equals("CANCELLED", StringComparison.OrdinalIgnoreCase)) + ledgerStatus = LedgerStatuses.Cancelled; + else + ledgerStatus = LedgerStatuses.Failed; + + var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry + { + OrderId = orderResult.Value.Id, + PaymentId = paymentResult.Value.Id, + PaymentGatewayReference = payload.PaymentId!, + Status = ledgerStatus, + CustomerId = orderResult.Value.CustomerId, + }, cancellationToken); + + logger.LogInformation("Webhook validation pipeline passed checks successfully, logged entry to ledger with status: {Status}", status); } - - var orderCompletedResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken); - if (orderCompletedResult.IsFailed) - { - logger.LogError("Failed to update order status to Completed for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", orderCompletedResult.Errors.Select(e => e.Message))); - throw new Exception($"Failed to update order status to Completed for order ID: {orderResult.Value.Id}."); - } - - logger.LogInformation("Received Payfast payment confirmation for payment ID: {PaymentId}", notification.Payload?.MPaymentId); - - // TODO: Publish MediatR notifications or queue downstream Quartz jobs (Discord, Shipping, Customer Email, Royalties) } } diff --git a/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs b/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs index b322753..b95d292 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs @@ -1,5 +1,5 @@ using LiteCharms.Features.Abstractions; -using LiteCharms.Features.Models; +using LiteCharms.Features.MidrandBooks.Payments.Models; namespace LiteCharms.Features.MidrandBooks.Payments.Events; @@ -9,14 +9,19 @@ public sealed class PayfastPaymentConfirmationReceivedEvent : EventBase, IEvent public PayfastWebhookPayload? Payload { get; set; } + public string? RemoteIpAddress { get; set; } + + public bool PerformBackgroundChecks { get; set; } + public PayfastPaymentConfirmationReceivedEvent() { } - private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId) + private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true) { Payload = payload; CorrelationId = paymentId; + PerformBackgroundChecks = performBackgroundChecks; } - public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId) => - new(payload, paymentId); + public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true) => + new(payload, paymentId, performBackgroundChecks); } diff --git a/LiteCharms.Features.MidrandBooks/Payments/Models/PayfastWebhookPayload.cs b/LiteCharms.Features.MidrandBooks/Payments/Models/PayfastWebhookPayload.cs new file mode 100644 index 0000000..b0d8da7 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Models/PayfastWebhookPayload.cs @@ -0,0 +1,59 @@ +namespace LiteCharms.Features.MidrandBooks.Payments.Models; + +public sealed class PayfastWebhookPayload +{ + public string? MerchantId { get; set; } + + public string? MerchantKey { get; set; } + + public string? Signature { get; set; } + + public string? MerchantPaymentId { get; set; } + + public string? PaymentId { get; set; } + + public string? PaymentStatus { get; set; } + + public string? ItemName { get; set; } + + public string? ItemDescription { get; set; } + + public string? AmountGross { get; set; } + + public string? AmountFee { get; set; } + + public string? AmountNet { get; set; } + + public string? NameFirst { get; set; } + + public string? NameLast { get; set; } + + public string? EmailAddress { get; set; } + + public string? CustomStr1 { get; set; } + + public string? CustomInt1 { get; set; } + + public string? Token { get; set; } + + public IDictionary ToParamDictionary() => new Dictionary + (StringComparer.Ordinal) + { + { "merchant_id", MerchantId }, + { "merchant_key", MerchantKey }, + { "m_payment_id", MerchantPaymentId }, + { "pf_payment_id", PaymentId }, + { "payment_status", PaymentStatus }, + { "item_name", ItemName }, + { "item_description", ItemDescription }, + { "amount_gross", AmountGross }, + { "amount_fee", AmountFee }, + { "amount_net", AmountNet }, + { "custom_str1", CustomStr1 }, + { "custom_int1", CustomInt1 }, + { "name_first", NameFirst }, + { "name_last", NameLast }, + { "email_address", EmailAddress }, + { "token", Token } + }; +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentGatewayLedger.cs b/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentGatewayLedger.cs new file mode 100644 index 0000000..2e27057 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentGatewayLedger.cs @@ -0,0 +1,26 @@ +namespace LiteCharms.Features.MidrandBooks.Payments.Models; + +public class PaymentGatewayLedger +{ + public long Id { get; set; } + + public DateTime CreatedAt { get; set; } + + public string? CustomerEmail { get; set; } + + public long OrderId { get; set; } + + public long PaymentId { get; set; } + + public string? MerchantPaymentId { get; set; } + + public string? PayfastPaymentId { get; set; } + + public string? PaymentStatus { get; set; } + + public decimal AmountGross { get; set; } + + public decimal AmountFee { get; set; } + + public decimal AmountNet { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentLedger.cs b/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentLedger.cs index 33f3d25..d650698 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentLedger.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/Models/PaymentLedger.cs @@ -14,7 +14,5 @@ public class PaymentLedger public long CustomerId { get; set; } - public string? PaymentGatewayReference { get; set; } - - public long? PaymentGatewayId { get; set; } + public string? MerchantPaymentId { get; set; } } diff --git a/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs b/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs index 2326397..f403b20 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs @@ -1,5 +1,26 @@ namespace LiteCharms.Features.MidrandBooks.Payments.Models; +public sealed record CreateGatewayLedgerEntry +{ + public string? CustomerEmail { get; set; } + + public required long OrderId { get; set; } + + public required long PaymentId { get; set; } + + public string? MerchantPaymentId { get; set; } + + public string? PayfastPaymentId { get; set; } + + public string? PaymentStatus { get; set; } + + public decimal AmountGross { get; set; } + + public decimal AmountFee { get; set; } + + public decimal AmountNet { get; set; } +} + public sealed record UpdateRefund { public long OrderId { get; set; } diff --git a/LiteCharms.Features.MidrandBooks/Payments/PayfastService.cs b/LiteCharms.Features.MidrandBooks/Payments/PayfastService.cs new file mode 100644 index 0000000..3329c99 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/PayfastService.cs @@ -0,0 +1,171 @@ +using LiteCharms.Features.Abstractions; +using LiteCharms.Features.Hasher; +using LiteCharms.Features.MidrandBooks.Payments.Models; +using LiteCharms.Features.MidrandBooks.Postgres; + +namespace LiteCharms.Features.MidrandBooks.Payments; + +public sealed partial class PayfastService(IDbContextFactory contextFactory, + ILogger logger, IHttpClientFactory httpClientFactory, IConfiguration configuration) : IService +{ + [GeneratedRegex(@"%[0-9A-Fa-f]{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + public static partial Regex PercentEncodingRegex { get; } + + public readonly string[] ValidHosts = configuration.GetSection("ValidPayfastHosts").Get() ?? []; + + public async ValueTask> WriteLedgerEntryAsync(CreateGatewayLedgerEntry request, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if(!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken)) + return Result.Fail("Referenced order ID does not exist in database."); + + if(!await context.Payments.AnyAsync(p => p.Id == request.PaymentId, cancellationToken)) + return Result.Fail("Referenced payment ID does not exist in database."); + + var entry = context.GatewayLedger.Add(new Entities.PaymentGatewayLedger + { + CustomerEmail = request.CustomerEmail, + OrderId = request.OrderId, + PaymentId = request.PaymentId, + MerchantPaymentId = request.MerchantPaymentId, + PayfastPaymentId = request.PayfastPaymentId, + PaymentStatus = request.PaymentStatus, + AmountGross = request.AmountGross, + AmountFee = request.AmountFee, + AmountNet = request.AmountNet, + CreatedAt = DateTime.UtcNow, + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(entry.Entity.Id) + : Result.Fail("Failed to save Payfast ledger entry to database."); + } + catch (Exception ex) + { + return Result.Fail(new Error("Failed to write Payfast ledger entry to database.").CausedBy(ex)); + } + } + + public async ValueTask> ValidateReferrerIpAsync(string remoteIpAddress, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(remoteIpAddress)) + return Result.Fail("Remote IP address is null or whitespace."); + + try + { + var validIps = new HashSet(); + + foreach (var host in ValidHosts) + { + try + { + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + + foreach (var addr in addresses) validIps.Add(addr); + } + catch (SocketException ex) + { + logger.LogWarning(ex, "DNS warning: Failed to resolve Payfast node '{Host}'. It may be decommissioned or unreachable.", host); + } + } + + if (IPAddress.TryParse(remoteIpAddress, out var incomingIp)) + { + bool isValid = validIps.Contains(incomingIp); + + if (!isValid) + logger.LogWarning("SECURITY ALERT: Webhook IP '{RemoteIp}' originated from an unlisted host schema.", remoteIpAddress); + + return Result.Ok(isValid); + } + + return Result.Fail("Invalid remote IP address format."); + } + catch (Exception ex) + { + return Result.Fail(new Error("DNS Verification error while scanning Payfast IP nodes.").CausedBy(ex)); + } + } + + public Result ValidatePaymentAmount(decimal expectedTotal, string? amountGrossString) + { + if (!decimal.TryParse(amountGrossString, CultureInfo.InvariantCulture, out decimal grossAmount)) + return Result.Fail("Failed to parse payment amount."); + + decimal delta = Math.Abs(expectedTotal - grossAmount); + + bool isAmountValid = delta <= 0.01m; + + if (!isAmountValid) + logger.LogError("FINANCIAL DRIFT EXCEPTION: Expected order total R{Expected} but gateway cleared R{Cleared}.", expectedTotal, grossAmount); + + return Result.Ok(isAmountValid); + } + + public async ValueTask> ValidateServerConfirmationAsync(string rawQueryParamString, bool isSandbox, CancellationToken ct) + { + try + { + string host = isSandbox ? "sandbox.payfast.co.za" : "www.payfast.co.za"; + string targetUrl = $"https://{host}/eng/query/validate"; + + using var content = new StringContent(rawQueryParamString, Encoding.UTF8, "application/x-www-form-urlencoded"); + + var httpClient = httpClientFactory.CreateClient(); + + var response = await httpClient.PostAsync(targetUrl, content, ct); + + if (!response.IsSuccessStatusCode) return Result.Fail("Failed to validate server confirmation."); + + string responseText = await response.Content.ReadAsStringAsync(ct); + + bool isValidated = string.Equals(responseText.Trim(), "VALID", StringComparison.OrdinalIgnoreCase); + + if (!isValidated) + logger.LogWarning("SECURITY WARNING: Payfast back-channel returned validation response: '{Response}'", responseText); + + return Result.Ok(isValidated); + } + catch (Exception ex) + { + return Result.Fail(new Error("Failed to complete back-channel cURL verification handshakes with Payfast remote endpoints.").CausedBy(ex)); + } + } + + public static Result GenerateSignature(IDictionary data, string? passPhrase = null) + { + var pfOutput = new StringBuilder(); + + foreach (var kvp in data) + { + if (string.IsNullOrEmpty(kvp.Value)) + continue; + + string key = kvp.Key; + + string encodedVal = HttpUtility.UrlEncode(kvp.Value.Trim()); + + string val = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToLowerInvariant()); + + pfOutput.Append($"{key}={val}&"); + } + + string getString = pfOutput.Length > 0 + ? pfOutput.ToString()[..^1] + : string.Empty; + + if (!string.IsNullOrWhiteSpace(passPhrase)) + { + string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim()); + + string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToLowerInvariant()); + + getString += $"&passphrase={safePassphrase}"; + } + + return HashService.ToMd5Hash(getString); + } +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs index 7904491..97e9e21 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs @@ -116,7 +116,7 @@ public sealed class PaymentService(IDbContextFactory cont } } - public async ValueTask> HasLedgerEntryAsync(long orderId, long paymentId, long gatewayId, CancellationToken cancellationToken = default) + public async ValueTask> HasLedgerEntryAsync(long orderId, long paymentId, CancellationToken cancellationToken = default) { try { @@ -124,8 +124,7 @@ public sealed class PaymentService(IDbContextFactory cont var exists = await context.Ledger.AnyAsync(l => l.OrderId == orderId && - l.PaymentId == paymentId && - l.PaymentGatewayId == gatewayId, cancellationToken); + l.PaymentId == paymentId, cancellationToken); return Result.Ok(exists); } @@ -162,8 +161,6 @@ public sealed class PaymentService(IDbContextFactory cont CreatedAt = DateTime.UtcNow, CustomerId = request.CustomerId, OrderId = request.OrderId, - PaymentGatewayId = request.PaymentGatewayId, - PaymentGatewayReference = request.PaymentGatewayReference, PaymentId = request.PaymentId, Status = request.Status, }); diff --git a/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs b/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs index 3d0b380..822b1c0 100644 --- a/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs +++ b/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs @@ -48,4 +48,6 @@ public sealed class MidrandBooksDbContext(DbContextOptions Gateways => Set(); public DbSet Ledger => Set(); + + public DbSet GatewayLedger => Set(); } diff --git a/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.Designer.cs b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.Designer.cs new file mode 100644 index 0000000..6266a05 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.Designer.cs @@ -0,0 +1,1291 @@ +// +using System; +using LiteCharms.Features.MidrandBooks.Postgres; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations +{ + [DbContext(typeof(MidrandBooksDbContext))] + [Migration("20260602202421_AddedPaymentGatewayLedger")] + partial class AddedPaymentGatewayLedger + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Ranking") + .HasColumnType("integer"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Biography") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Company") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PublisherType") + .HasColumnType("integer"); + + b.Property("ThumbnailImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("VatNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Website") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.ToTable("Authors", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsMain") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.HasKey("Id"); + + b.ToTable("Categories", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BuildingType") + .HasColumnType("integer"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Country") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PostalCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("Street") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Addresses", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Company") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("VatNumber") + .HasColumnType("text"); + + b.Property("Website") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Customers", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("InvoiceUrl") + .HasColumnType("text"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorBookId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("ProductPriceId") + .HasColumnType("bigint"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorBookId"); + + b.HasIndex("OrderId"); + + b.HasIndex("ProductPriceId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddressId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("ShippingProviderId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TrackingNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ShippingProviderId"); + + b.ToTable("Shippings", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("TrackingUrl") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ShippingProviders"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Pages.Entities.BookPage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorBookId") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ContentType") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.PrimitiveCollection("Notes") + .HasColumnType("text[]"); + + b.Property("Number") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("AuthorBookId"); + + b.ToTable("BookPages", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsSandbox") + .HasColumnType("boolean"); + + b.Property("MerchantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("MerchantKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Gateways", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AmountGross") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AmountNet") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerEmail") + .HasColumnType("text"); + + b.Property("MerchantPaymentId") + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PayfastPaymentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentId") + .HasColumnType("bigint"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("PaymentId"); + + b.ToTable("GatewayLedger", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("MerchantPaymentId") + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PaymentId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("OrderId"); + + b.HasIndex("PaymentId"); + + b.ToTable("Ledger", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("Refunds", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.PrimitiveCollection("ThumbnailUrls") + .HasColumnType("text[]"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductCategories", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductInventory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("ProductPriceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalAllocated") + .HasColumnType("integer"); + + b.Property("TotalReserved") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("ProductPriceId"); + + b.ToTable("Inventories", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Discount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("Prices", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b => + { + b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 => + { + b1.Property("AuthorId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("ImageUrl"); + + b1.Property("Name"); + + b1.Property("Type"); + + b1.Property("Url"); + + b1.HasKey("AuthorId", "__synthesizedOrdinal"); + + b1.ToTable("Authors"); + + b1 + .ToJson("SocialMedia") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("AuthorId"); + }); + + b.Navigation("SocialMedia"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") + .WithMany("Addresses") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Contact", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") + .WithMany("Contacts") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b => + { + b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 => + { + b1.Property("CustomerId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("ImageUrl"); + + b1.Property("Name"); + + b1.Property("Type"); + + b1.Property("Url"); + + b1.HasKey("CustomerId", "__synthesizedOrdinal"); + + b1.ToTable("Customers"); + + b1 + .ToJson("SocialMedia") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("CustomerId"); + }); + + b.Navigation("SocialMedia"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "AuthorBook") + .WithMany() + .HasForeignKey("AuthorBookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", "ProductPrice") + .WithMany() + .HasForeignKey("ProductPriceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AuthorBook"); + + b.Navigation("Order"); + + b.Navigation("ProductPrice"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", "Address") + .WithMany() + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithOne("Shipping") + .HasForeignKey("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", "OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", "ShippingProvider") + .WithMany("Shippings") + .HasForeignKey("ShippingProviderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Address"); + + b.Navigation("Order"); + + b.Navigation("ShippingProvider"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Pages.Entities.BookPage", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "Book") + .WithMany("Pages") + .HasForeignKey("AuthorBookId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.OwnsMany("LiteCharms.Features.Models.PageReference", "References", b1 => + { + b1.Property("BookPageId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Description"); + + b1.Property("Tag"); + + b1.Property("Url"); + + b1.HasKey("BookPageId", "__synthesizedOrdinal"); + + b1.ToTable("BookPages"); + + b1 + .ToJson("References") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BookPageId"); + }); + + b.Navigation("Book"); + + b.Navigation("References"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Order"); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany("Refunds") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b => + { + b.OwnsOne("LiteCharms.Features.Models.ProductMetadata", "Metadata", b1 => + { + b1.Property("ProductId"); + + b1.Property("CopyrightInfo"); + + b1.Property("ManufactureDate"); + + b1.Property("Manufacturer"); + + b1.Property("SerialNumber"); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1 + .ToJson("Metadata") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductCategory", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany("Categories") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductInventory", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", "Price") + .WithMany() + .HasForeignKey("ProductPriceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Price"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b => + { + b.Navigation("Pages"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", b => + { + b.Navigation("OrderItems"); + + b.Navigation("Refunds"); + + b.Navigation("Shipping"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", b => + { + b.Navigation("Shippings"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b => + { + b.Navigation("Categories"); + + b.Navigation("Prices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.cs b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.cs new file mode 100644 index 0000000..b4cc909 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602202421_AddedPaymentGatewayLedger.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations +{ + /// + public partial class AddedPaymentGatewayLedger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Ledger_Gateways_PaymentGatewayId", + table: "Ledger"); + + migrationBuilder.DropIndex( + name: "IX_Ledger_PaymentGatewayId", + table: "Ledger"); + + migrationBuilder.DropColumn( + name: "PaymentGatewayId", + table: "Ledger"); + + migrationBuilder.RenameColumn( + name: "PaymentGatewayReference", + table: "Ledger", + newName: "MerchantPaymentId"); + + migrationBuilder.CreateTable( + name: "GatewayLedger", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CustomerEmail = table.Column(type: "text", nullable: true), + OrderId = table.Column(type: "bigint", nullable: false), + PaymentId = table.Column(type: "bigint", nullable: false), + MerchantPaymentId = table.Column(type: "text", nullable: true), + PayfastPaymentId = table.Column(type: "text", nullable: false), + PaymentStatus = table.Column(type: "text", nullable: true), + AmountGross = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + AmountFee = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + AmountNet = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GatewayLedger", x => x.Id); + table.ForeignKey( + name: "FK_GatewayLedger_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GatewayLedger_Payments_PaymentId", + column: x => x.PaymentId, + principalTable: "Payments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GatewayLedger_OrderId", + table: "GatewayLedger", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_GatewayLedger_PaymentId", + table: "GatewayLedger", + column: "PaymentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GatewayLedger"); + + migrationBuilder.RenameColumn( + name: "MerchantPaymentId", + table: "Ledger", + newName: "PaymentGatewayReference"); + + migrationBuilder.AddColumn( + name: "PaymentGatewayId", + table: "Ledger", + type: "bigint", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Ledger_PaymentGatewayId", + table: "Ledger", + column: "PaymentGatewayId"); + + migrationBuilder.AddForeignKey( + name: "FK_Ledger_Gateways_PaymentGatewayId", + table: "Ledger", + column: "PaymentGatewayId", + principalTable: "Gateways", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.Designer.cs b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.Designer.cs new file mode 100644 index 0000000..6ccd5e5 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.Designer.cs @@ -0,0 +1,1292 @@ +// +using System; +using LiteCharms.Features.MidrandBooks.Postgres; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations +{ + [DbContext(typeof(MidrandBooksDbContext))] + [Migration("20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger")] + partial class AddedPayfastPaymentIdToPaymentGatewayLedger + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Ranking") + .HasColumnType("integer"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Biography") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Company") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PublisherType") + .HasColumnType("integer"); + + b.Property("ThumbnailImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("VatNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Website") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.ToTable("Authors", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsMain") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.HasKey("Id"); + + b.ToTable("Categories", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BuildingType") + .HasColumnType("integer"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Country") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PostalCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("Street") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Addresses", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Company") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("VatNumber") + .HasColumnType("text"); + + b.Property("Website") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Customers", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("InvoiceUrl") + .HasColumnType("text"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorBookId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("ProductPriceId") + .HasColumnType("bigint"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorBookId"); + + b.HasIndex("OrderId"); + + b.HasIndex("ProductPriceId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddressId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("ShippingProviderId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TrackingNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ShippingProviderId"); + + b.ToTable("Shippings", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("TrackingUrl") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ShippingProviders"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Pages.Entities.BookPage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorBookId") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ContentType") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.PrimitiveCollection("Notes") + .HasColumnType("text[]"); + + b.Property("Number") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("AuthorBookId"); + + b.ToTable("BookPages", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsSandbox") + .HasColumnType("boolean"); + + b.Property("MerchantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("MerchantKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Gateways", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AmountGross") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AmountNet") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerEmail") + .HasColumnType("text"); + + b.Property("MerchantPaymentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PayfastPaymentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentId") + .HasColumnType("bigint"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("PaymentId"); + + b.ToTable("GatewayLedger", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("MerchantPaymentId") + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PaymentId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("OrderId"); + + b.HasIndex("PaymentId"); + + b.ToTable("Ledger", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("Refunds", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.PrimitiveCollection("ThumbnailUrls") + .HasColumnType("text[]"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductCategories", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductInventory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("ProductPriceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalAllocated") + .HasColumnType("integer"); + + b.Property("TotalReserved") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("ProductPriceId"); + + b.ToTable("Inventories", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Discount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("Prices", (string)null); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b => + { + b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 => + { + b1.Property("AuthorId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("ImageUrl"); + + b1.Property("Name"); + + b1.Property("Type"); + + b1.Property("Url"); + + b1.HasKey("AuthorId", "__synthesizedOrdinal"); + + b1.ToTable("Authors"); + + b1 + .ToJson("SocialMedia") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("AuthorId"); + }); + + b.Navigation("SocialMedia"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") + .WithMany("Addresses") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Contact", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") + .WithMany("Contacts") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b => + { + b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 => + { + b1.Property("CustomerId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("ImageUrl"); + + b1.Property("Name"); + + b1.Property("Type"); + + b1.Property("Url"); + + b1.HasKey("CustomerId", "__synthesizedOrdinal"); + + b1.ToTable("Customers"); + + b1 + .ToJson("SocialMedia") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("CustomerId"); + }); + + b.Navigation("SocialMedia"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "AuthorBook") + .WithMany() + .HasForeignKey("AuthorBookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", "ProductPrice") + .WithMany() + .HasForeignKey("ProductPriceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AuthorBook"); + + b.Navigation("Order"); + + b.Navigation("ProductPrice"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", "Address") + .WithMany() + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithOne("Shipping") + .HasForeignKey("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", "OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", "ShippingProvider") + .WithMany("Shippings") + .HasForeignKey("ShippingProviderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Address"); + + b.Navigation("Order"); + + b.Navigation("ShippingProvider"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Pages.Entities.BookPage", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "Book") + .WithMany("Pages") + .HasForeignKey("AuthorBookId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.OwnsMany("LiteCharms.Features.Models.PageReference", "References", b1 => + { + b1.Property("BookPageId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Description"); + + b1.Property("Tag"); + + b1.Property("Url"); + + b1.HasKey("BookPageId", "__synthesizedOrdinal"); + + b1.ToTable("BookPages"); + + b1 + .ToJson("References") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BookPageId"); + }); + + b.Navigation("Book"); + + b.Navigation("References"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Order"); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany("Refunds") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b => + { + b.OwnsOne("LiteCharms.Features.Models.ProductMetadata", "Metadata", b1 => + { + b1.Property("ProductId"); + + b1.Property("CopyrightInfo"); + + b1.Property("ManufactureDate"); + + b1.Property("Manufacturer"); + + b1.Property("SerialNumber"); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1 + .ToJson("Metadata") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductCategory", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany("Categories") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductInventory", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", "Price") + .WithMany() + .HasForeignKey("ProductPriceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Price"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b => + { + b.Navigation("Pages"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", b => + { + b.Navigation("OrderItems"); + + b.Navigation("Refunds"); + + b.Navigation("Shipping"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", b => + { + b.Navigation("Shippings"); + }); + + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b => + { + b.Navigation("Categories"); + + b.Navigation("Prices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.cs b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.cs new file mode 100644 index 0000000..e0853da --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/20260602205838_AddedPayfastPaymentIdToPaymentGatewayLedger.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations +{ + /// + public partial class AddedPayfastPaymentIdToPaymentGatewayLedger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "MerchantPaymentId", + table: "GatewayLedger", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "MerchantPaymentId", + table: "GatewayLedger", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/Postgres/Migrations/MidrandBooksDbContextModelSnapshot.cs b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/MidrandBooksDbContextModelSnapshot.cs index 79de829..8ddc085 100644 --- a/LiteCharms.Features.MidrandBooks/Postgres/Migrations/MidrandBooksDbContextModelSnapshot.cs +++ b/LiteCharms.Features.MidrandBooks/Postgres/Migrations/MidrandBooksDbContextModelSnapshot.cs @@ -615,6 +615,60 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations b.ToTable("Gateways", (string)null); }); + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AmountGross") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AmountNet") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CustomerEmail") + .HasColumnType("text"); + + b.Property("MerchantPaymentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PayfastPaymentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentId") + .HasColumnType("bigint"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("PaymentId"); + + b.ToTable("GatewayLedger", (string)null); + }); + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => { b.Property("Id") @@ -631,15 +685,12 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations b.Property("CustomerId") .HasColumnType("bigint"); + b.Property("MerchantPaymentId") + .HasColumnType("text"); + b.Property("OrderId") .HasColumnType("bigint"); - b.Property("PaymentGatewayId") - .HasColumnType("bigint"); - - b.Property("PaymentGatewayReference") - .HasColumnType("text"); - b.Property("PaymentId") .HasColumnType("bigint"); @@ -652,8 +703,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations b.HasIndex("OrderId"); - b.HasIndex("PaymentGatewayId"); - b.HasIndex("PaymentId"); b.ToTable("Ledger", (string)null); @@ -1062,6 +1111,25 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations b.Navigation("Order"); }); + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b => + { + b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Payment"); + }); + modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => { b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") @@ -1076,11 +1144,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGateway", "Gateway") - .WithMany() - .HasForeignKey("PaymentGatewayId") - .OnDelete(DeleteBehavior.Cascade); - b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") .WithMany() .HasForeignKey("PaymentId") @@ -1089,8 +1152,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations b.Navigation("Customer"); - b.Navigation("Gateway"); - b.Navigation("Order"); b.Navigation("Payment"); diff --git a/LiteCharms.Features.Tests/HashServiceFeatureTests.cs b/LiteCharms.Features.Tests/HashServiceFeatureTests.cs index 6d85422..844c7fb 100644 --- a/LiteCharms.Features.Tests/HashServiceFeatureTests.cs +++ b/LiteCharms.Features.Tests/HashServiceFeatureTests.cs @@ -1,13 +1,10 @@ using LiteCharms.Features.Hasher; -using LiteCharms.Features.Models; -using static LiteCharms.Features.Extensions.Hash; namespace LiteCharms.Features.Tests; public class HashServiceFeatureTests(Fixture fixture) : IClassFixture { private readonly HashService hashService = fixture.Services.GetRequiredService(); - private readonly string payfastPassphrase = fixture.Configuration.GetSection("HasherSettings:PayfastPassphrase").Value!; [Fact] public void StringToSha256Hash_Should_GenerateHash() @@ -62,28 +59,6 @@ public class HashServiceFeatureTests(Fixture fixture) : IClassFixture Assert.Equal(expectedMd5Lowercase, result.Value); } - [Fact] - public void VerifyPayfastWebhookSignature_Should_GenerateHash() - { - var paymentId = hashService.HashEncodeLongId(1001).Value; - - var payload = new PayfastWebhookPayload - { - Amount = "350.00", - ItemName = "System Architecture Book", - MPaymentId = paymentId, - }; - - var rawPayload = payload.ToRawPayfastPayload(payfastPassphrase); - - var generatedSignature = HashService.ToMd5Hash(rawPayload).Value; - - var result = hashService.VerifyPayfastWebhookSignature(payload, generatedSignature); - - Assert.True(result.IsSuccess); - Assert.True(result.Value); - } - [Fact] public void HashEncodeHex_Should_GenerateHash() { diff --git a/LiteCharms.Features/Enums.cs b/LiteCharms.Features/Enums.cs index 786d3e5..296848e 100644 --- a/LiteCharms.Features/Enums.cs +++ b/LiteCharms.Features/Enums.cs @@ -19,6 +19,7 @@ public enum LedgerStatuses : int Cancelled = 4, Failed = 5, Partial = 6, + Completed = 7, } public enum PaymentStatuses : int diff --git a/LiteCharms.Features/Extensions/Hash.cs b/LiteCharms.Features/Extensions/Hash.cs index 78f2c6c..555c423 100644 --- a/LiteCharms.Features/Extensions/Hash.cs +++ b/LiteCharms.Features/Extensions/Hash.cs @@ -1,6 +1,5 @@ using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher.Configuration; -using LiteCharms.Features.Models; namespace LiteCharms.Features.Extensions; @@ -20,83 +19,5 @@ public static class Hash services.AddSingleton(); return services; - } - - public static string ToRawPayfastPayload(this PayfastWebhookPayload input, string passphrase) - { - var parameters = new List(); - - if (!string.IsNullOrWhiteSpace(input.Amount)) - parameters.Add($"amount={WebUtility.UrlEncode(input.Amount)}"); - - if (!string.IsNullOrWhiteSpace(input.ItemName)) - parameters.Add($"item_name={WebUtility.UrlEncode(input.ItemName)}"); - - if (!string.IsNullOrWhiteSpace(input.MPaymentId)) - parameters.Add($"m_payment_id={WebUtility.UrlEncode(input.MPaymentId)}"); - - string payload = string.Join("&", parameters); - - if (!string.IsNullOrWhiteSpace(passphrase)) - payload += $"&passphrase={WebUtility.UrlEncode(passphrase)}"; - - return payload; - } - - public static (PayfastWebhookPayload Payload, string Passphrase) FromRawPayfastPayload(this string rawPayload) - { - string passphrase = string.Empty; - var payload = new PayfastWebhookPayload(); - - if (string.IsNullOrWhiteSpace(rawPayload)) return (payload, passphrase); - - var segments = rawPayload.Split('&', StringSplitOptions.RemoveEmptyEntries); - - foreach (var segment in segments) - { - int delimiterIndex = segment.IndexOf('='); - if (delimiterIndex == -1) - continue; - - string key = segment[..delimiterIndex].Trim(); - string rawValue = segment[(delimiterIndex + 1)..]; - - string decodedValue = WebUtility.UrlDecode(rawValue); - - switch (key.ToLowerInvariant()) - { - case "amount": - payload.Amount = decodedValue; - break; - case "item_name": - payload.ItemName = decodedValue; - break; - case "m_payment_id": - payload.MPaymentId = decodedValue; - break; - case "passphrase": - passphrase = decodedValue; - break; - } - } - - return (payload, passphrase); - } - - public static (PayfastWebhookPayload Payload, string Passphrase) FromRawPayfastPayload(this IFormCollection form) - { - string passphrase = string.Empty; - var payload = new PayfastWebhookPayload(); - - if (form.IsNullOrEmpty()) return (payload, passphrase); - - payload = new PayfastWebhookPayload - { - Amount = form.TryGetValue("amount", out var amountValues) ? amountValues.ToString() : null, - ItemName = form.TryGetValue("item_name", out var itemValues) ? itemValues.ToString() : null, - MPaymentId = form.TryGetValue("m_payment_id", out var paymentIdValues) ? paymentIdValues.ToString() : null, - }; - - return (payload, passphrase); - } + } } \ No newline at end of file diff --git a/LiteCharms.Features/Hasher/HashService.cs b/LiteCharms.Features/Hasher/HashService.cs index 1b38ed7..660c9e5 100644 --- a/LiteCharms.Features/Hasher/HashService.cs +++ b/LiteCharms.Features/Hasher/HashService.cs @@ -1,13 +1,9 @@ using LiteCharms.Features.Abstractions; -using LiteCharms.Features.Hasher.Configuration; -using LiteCharms.Features.Models; namespace LiteCharms.Features.Hasher; -public sealed partial class HashService(IHashids hasher, IOptions options) : IService +public sealed partial class HashService(IHashids hasher) : IService { - private readonly HasherSettings settings = options.Value; - [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")] private static partial Regex HexHashRegex { get; } @@ -41,44 +37,6 @@ public sealed partial class HashService(IHashids hasher, IOptions VerifyPayfastWebhookSignature(PayfastWebhookPayload payload, string incomingSignature) - { - try - { - if (string.IsNullOrWhiteSpace(incomingSignature)) - return Result.Fail("Validation failed: Missing signature string parameter."); - - var parameters = new List(); - - if (!string.IsNullOrWhiteSpace(payload.Amount)) - parameters.Add($"amount={WebUtility.UrlEncode(payload.Amount)}"); - - if (!string.IsNullOrWhiteSpace(payload.ItemName)) - parameters.Add($"item_name={WebUtility.UrlEncode(payload.ItemName)}"); - - if (!string.IsNullOrWhiteSpace(payload.MPaymentId)) - parameters.Add($"m_payment_id={WebUtility.UrlEncode(payload.MPaymentId)}"); - - string signatureString = string.Join("&", parameters); - - if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase)) - signatureString += $"&passphrase={WebUtility.UrlEncode(settings.PayfastPassphrase)}"; - - var localHashResult = ToMd5Hash(signatureString); - - if (!localHashResult.IsSuccess) - return Result.Fail(localHashResult.Errors); - - bool isValid = string.Equals(localHashResult.Value, incomingSignature, StringComparison.OrdinalIgnoreCase); - - return Result.Ok(isValid); - } - catch (Exception ex) - { - return Result.Fail(new Error("An error occurred during Payfast MD5 verification.").CausedBy(ex)); - } - } - public Result HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input) ? Result.Fail("Input must be a valid hexadecimal string.") : Result.Ok(hasher.EncodeHex(input)); diff --git a/LiteCharms.Features/LiteCharms.Features.csproj b/LiteCharms.Features/LiteCharms.Features.csproj index 087c9e4..30e1c10 100644 --- a/LiteCharms.Features/LiteCharms.Features.csproj +++ b/LiteCharms.Features/LiteCharms.Features.csproj @@ -147,6 +147,7 @@ + diff --git a/LiteCharms.Features/Models/PayfastWebhookPayload.cs b/LiteCharms.Features/Models/PayfastWebhookPayload.cs deleted file mode 100644 index 6b0a5db..0000000 --- a/LiteCharms.Features/Models/PayfastWebhookPayload.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace LiteCharms.Features.Models; - -public sealed class PayfastWebhookPayload -{ - public string? Amount { get; set; } - public string? ItemName { get; set; } - public string? MPaymentId { get; set; } -}