diff --git a/LiteCharms.Features.MidrandBooks.Seed/LiteCharms.Features.MidrandBooks.Seed.csproj b/LiteCharms.Features.MidrandBooks.Seed/LiteCharms.Features.MidrandBooks.Seed.csproj
index b9be4ff..d8ac700 100644
--- a/LiteCharms.Features.MidrandBooks.Seed/LiteCharms.Features.MidrandBooks.Seed.csproj
+++ b/LiteCharms.Features.MidrandBooks.Seed/LiteCharms.Features.MidrandBooks.Seed.csproj
@@ -116,8 +116,8 @@
-
-
+
+
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..a4b86f0 100644
--- a/LiteCharms.Features.MidrandBooks.Tests/LiteCharms.Features.MidrandBooks.Tests.csproj
+++ b/LiteCharms.Features.MidrandBooks.Tests/LiteCharms.Features.MidrandBooks.Tests.csproj
@@ -17,7 +17,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
@@ -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..3db773c 100644
--- a/LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj
+++ b/LiteCharms.Features.MidrandBooks/LiteCharms.Features.MidrandBooks.csproj
@@ -136,8 +136,8 @@
-
-
+
+
@@ -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