Compare commits

...
63 Commits
Author SHA1 Message Date
khwezi 780415b6d4 Merge pull request 'Fixed event service scope issue' (#58) from payments into master
Reviewed-on: #58
2026-06-01 23:33:11 +02:00
Khwezi Mngoma 139ca1f866 Fixed event service scope issue
continuous-integration/drone/pr Build is passing
2026-06-01 23:32:35 +02:00
khwezi 879094073a Merge pull request 'Added PayfastPaymentConfirmationReceivedEvent' (#57) from payments into master
Reviewed-on: #57
2026-06-01 22:52:40 +02:00
Khwezi Mngoma 45c2e8310a Added PayfastPaymentConfirmationReceivedEvent
continuous-integration/drone/pr Build is passing
2026-06-01 22:51:49 +02:00
khwezi b369dad452 Merge pull request 'Implemented overload taking in IFormCollection' (#56) from payments into master
Reviewed-on: #56
2026-06-01 17:03:33 +02:00
Khwezi Mngoma ac31c6ada8 Implemented overload taking in IFormCollection
continuous-integration/drone/pr Build is passing
2026-06-01 17:02:30 +02:00
khwezi c938bfec09 Merge pull request 'Refactored shasher payfast confirmation response handling' (#55) from payments into master
Reviewed-on: #55
2026-06-01 16:38:44 +02:00
Khwezi Mngoma 5eb6dbc8b2 Refactored shasher payfast confirmation response handling
continuous-integration/drone/pr Build is passing
2026-06-01 16:36:33 +02:00
khwezi adc14038db Merge pull request 'Fixed mdf file name hasher' (#54) from payments into master
Reviewed-on: #54
2026-06-01 09:27:54 +02:00
Khwezi Mngoma afc984f3bc Fixed mdf file name hasher
continuous-integration/drone/pr Build is passing
2026-06-01 09:25:46 +02:00
khwezi 61cb6c2228 Merge pull request 'payments' (#53) from payments into master
Reviewed-on: #53
2026-06-01 09:21:01 +02:00
khwezi e7a798b5e9 Merge pull request 'Added search state model' (#52) from midrandshop into master
Reviewed-on: #52
2026-05-30 19:54:57 +02:00
khwezi 41b6b71b31 Merge pull request 'Redacted Price resolution from GetProductAsync' (#51) from midrandshop into master
Reviewed-on: #51
2026-05-30 18:55:46 +02:00
khwezi ee6beef603 Merge pull request 'Redacted Product.Price mapping on filter' (#50) from midrandshop into master
Reviewed-on: #50
2026-05-30 18:49:38 +02:00
khwezi 1c3f3eaf0d Merge pull request 'Added a way to get the Author by productId' (#49) from midrandshop into master
Reviewed-on: #49
2026-05-30 18:20:21 +02:00
khwezi 2e77666d9e Merge pull request 'Added category seeder' (#48) from midrandshop into master
Reviewed-on: #48
2026-05-30 16:08:11 +02:00
khwezi 1977b6b301 Merge pull request 'midrandshop' (#47) from midrandshop into master
Reviewed-on: #47
2026-05-30 15:37:09 +02:00
khwezi 0ab14d8b63 Merge pull request 'midrandshop' (#46) from midrandshop into master
Reviewed-on: #46
2026-05-30 00:14:10 +02:00
khwezi 466458e230 Merge pull request 'Implemented Product Data Seeder' (#45) from midrandshop into master
Reviewed-on: #45
2026-05-29 18:56:45 +02:00
khwezi 141d32f591 Merge pull request 'Refactored migrations' (#44) from midrandshop into master
Reviewed-on: #44
2026-05-29 09:03:27 +02:00
khwezi d9e7f225ae Merge pull request 'midrandshop' (#43) from midrandshop into master
Reviewed-on: #43
2026-05-29 08:23:15 +02:00
khwezi 6ae63e2ad1 Merge pull request 'midrandshop' (#42) from midrandshop into master
Reviewed-on: #42
2026-05-27 09:15:44 +02:00
khwezi f5efdde37c Merge pull request 'Refactored connection string name' (#41) from midrandshop into master
Reviewed-on: #41
2026-05-24 13:51:11 +02:00
khwezi 1592d5dc8f Merge pull request 'midrandshop' (#40) from midrandshop into master
Reviewed-on: #40
2026-05-24 13:43:22 +02:00
khwezi 50a8a59d92 Merge pull request 'Split Features to create space for more projects' (#39) from midrandshop into master
Reviewed-on: #39
2026-05-24 13:19:55 +02:00
khwezi b70d9559b0 Merge pull request 'midrandshop' (#38) from midrandshop into master
Reviewed-on: #38
2026-05-23 11:55:02 +02:00
khwezi 81d5e8f07c Merge pull request 'Added MidrandShop feature and spl;it extensions and healthchecks' (#37) from midrandshop into master
Reviewed-on: #37
2026-05-23 11:49:27 +02:00
khwezi 20a53942b5 Merge pull request 'Added product metadata' (#36) from s3service into master
Reviewed-on: #36
2026-05-20 21:15:01 +02:00
khwezi 9edb2aa4aa Merge pull request 'Optimised UploadFileAsync()' (#35) from s3service into master
Reviewed-on: #35
2026-05-20 15:33:47 +02:00
khwezi 6ed023f2cf Merge pull request 'Refactored the S3 services to properly upload the file' (#34) from s3service into master
Reviewed-on: #34
2026-05-20 08:03:43 +02:00
khwezi 2c9f5a846c Merge pull request 'Updated how i use configs' (#33) from s3service into master
Reviewed-on: #33
2026-05-19 14:57:59 +02:00
khwezi 41f7c05be3 Merge pull request 'Refactored service to internalise the CDN' (#32) from s3service into master
Reviewed-on: #32
2026-05-19 11:34:51 +02:00
khwezi 1a03355e84 Merge pull request 'Added S3 support' (#31) from s3service into master
Reviewed-on: #31
2026-05-19 10:24:05 +02:00
khwezi 7743c3178e Merge pull request 'Simplified notification updating' (#30) from emailjobs into master
Reviewed-on: #30
2026-05-17 16:01:24 +02:00
khwezi ab3d8e6e9a Merge pull request 'Refactored GetNotificationsAsync() date handling' (#29) from emailjobs into master
Reviewed-on: #29
2026-05-17 13:14:01 +02:00
khwezi db4c348288 Merge pull request 'Fixed email sending logic' (#28) from emailjobs into master
Reviewed-on: #28
2026-05-16 00:29:01 +02:00
khwezi 6683234642 Merge pull request 'Refactored batch drop logic' (#27) from emailjobs into master
Reviewed-on: #27
2026-05-16 00:05:51 +02:00
khwezi 6ddbb9479a Merge pull request 'Added an empty constructor to ProcessEmailNotificationEvent' (#26) from emailjobs into master
Reviewed-on: #26
2026-05-15 23:53:09 +02:00
khwezi 6c7349a0f8 Merge pull request 'Added additional logging and traces' (#25) from emailjobs into master
Reviewed-on: #25
2026-05-15 23:21:52 +02:00
khwezi e97fd6cd3f Merge pull request 'Added debug logging' (#24) from emailjobs into master
Reviewed-on: #24
2026-05-15 23:09:21 +02:00
khwezi 184c7c252a Merge pull request 'Set misfireThreshold to 2min and eased Cluster node checkin limit' (#23) from emailjobs into master
Reviewed-on: #23
2026-05-15 22:29:08 +02:00
khwezi bfe8c458d6 Merge pull request 'Optimised quartz' (#22) from emailjobs into master
Reviewed-on: #22
2026-05-15 09:52:06 +02:00
khwezi e6e0475db1 Merge pull request 'emailjobs' (#21) from emailjobs into master
Reviewed-on: #21
2026-05-15 08:39:36 +02:00
khwezi 5090c60797 Merge pull request 'Fixed Lead->Customer Relationship' (#20) from emailjobs into master
Reviewed-on: #20
2026-05-15 07:55:56 +02:00
khwezi 9432252e15 Merge pull request 'Added khongisa host entry on pipeline commands' (#19) from emailjobs into master
Reviewed-on: #19
2026-05-14 02:54:00 +02:00
khwezi 47111a1a3a Merge pull request 'emailjobs' (#18) from emailjobs into master
Reviewed-on: #18
2026-05-14 02:49:34 +02:00
khwezi 6eb3d50375 Merge pull request 'Updated job scheduler' (#17) from emailjobs into master
Reviewed-on: #17
2026-05-10 17:33:23 +02:00
khwezi 4deb732804 Merge pull request 'emailjobs' (#16) from emailjobs into master
Reviewed-on: #16
2026-05-10 16:51:24 +02:00
khwezi 20d9387d0b Merge pull request 'Migrated database changes' (#15) from develop into master
Reviewed-on: #15
2026-05-10 11:17:54 +02:00
khwezi 9f6d0ccaa0 Merge pull request 'Populated README' (#14) from develop into master
Reviewed-on: #14
2026-05-10 09:48:35 +02:00
khwezi 1acbc4d213 Merge pull request 'fixed git repo naming' (#13) from develop into master
Reviewed-on: #13
2026-05-10 09:22:37 +02:00
khwezi 8c99668fac Merge pull request 'Added tag and release step after publish' (#12) from develop into master
Reviewed-on: #12
2026-05-10 09:19:33 +02:00
khwezi ad44f46204 Merge pull request 'Refactored Quartz instance id to AUTO, removed constant' (#11) from develop into master
Reviewed-on: #11
2026-05-10 08:21:08 +02:00
khwezi 49d999c1e3 Merge pull request 'Refactored postgres extension' (#10) from develop into master
Reviewed-on: #10
2026-05-09 17:45:00 +02:00
khwezi 9ed4777a18 Merge pull request 'Refactored database references' (#9) from develop into master
Reviewed-on: #9
2026-05-09 17:01:02 +02:00
khwezi 0cf44f68cc Merge pull request 'Added scheduler constants' (#8) from develop into master
Reviewed-on: #8
2026-05-09 15:27:50 +02:00
khwezi 41ed5a4288 Merge pull request 'Fixed quartz host config table prefix' (#7) from develop into master
Reviewed-on: #7
2026-05-09 13:30:33 +02:00
khwezi bbcba5e06c Merge pull request 'Fixed quartz table name prefix' (#6) from develop into master
Reviewed-on: #6
2026-05-09 13:28:29 +02:00
khwezi 502cc326dd Merge pull request 'Updated pipeline to use major version with minor always 0' (#5) from develop into master
Reviewed-on: #5
2026-05-09 12:02:19 +02:00
khwezi 4675d4c5fc Merge pull request 'Updated pipeline to use major versions only' (#4) from develop into master
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
Reviewed-on: #4
2026-05-09 11:56:20 +02:00
khwezi f80bb2fff9 Merge pull request 'Changed target branch from nain to master' (#3) from dronepipeline into master
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
Reviewed-on: #3
2026-05-09 11:37:39 +02:00
khwezi 6767906b0d Merge pull request 'Added pipeline separator' (#2) from dronepipeline into master
Reviewed-on: #2
2026-05-09 11:25:34 +02:00
khwezi a344af4498 Merge pull request 'Added .drone.yml pipeline' (#1) from dronepipeline into master
Reviewed-on: #1
2026-05-09 11:12:38 +02:00
10 changed files with 262 additions and 17 deletions
@@ -0,0 +1,84 @@
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Orders;
namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers;
public sealed class PayfastPaymentConfirmationReceivedEventHandler(IServiceProvider services, ILogger<PayfastPaymentConfirmationReceivedEvent> logger) :
INotificationHandler<PayfastPaymentConfirmationReceivedEvent>
{
private readonly PaymentService paymentService = services.GetRequiredService<PaymentService>();
private readonly OrderService orderService = services.GetRequiredService<OrderService>();
private readonly HashService hashService = services.GetRequiredService<HashService>();
public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken)
{
var hashResult = hashService.DecodeLongIdHash(notification.Payload?.MPaymentId!);
if (hashResult.IsFailed)
{
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}.");
}
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
{
CustomerId = orderResult.Value.CustomerId,
OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id,
Status = LedgerStatuses.Received,
PaymentGatewayId = 1,
PaymentGatewayReference = notification.CorrelationId,
}, cancellationToken);
if (ledgerResult.IsFailed)
{
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 paymentCompletedResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken);
if (paymentCompletedResult.IsFailed)
{
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}.");
}
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)
}
}
@@ -0,0 +1,22 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Events;
public sealed class PayfastPaymentConfirmationReceivedEvent : EventBase, IEvent
{
public string Name { get; set; } = nameof(PayfastPaymentConfirmationReceivedEvent);
public PayfastWebhookPayload? Payload { get; set; }
public PayfastPaymentConfirmationReceivedEvent() { }
private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId)
{
Payload = payload;
CorrelationId = paymentId;
}
public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId) =>
new(payload, paymentId);
}
@@ -7,6 +7,27 @@ namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{ {
public async ValueTask<Result<Payment>> GetOrderPaymentAsync(long orderId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var payment = await context.Payments.AsNoTracking()
.Where(p => p.OrderId == orderId)
.OrderByDescending(p => p.Id)
.FirstOrDefaultAsync(cancellationToken);
return payment is not null
? Result.Ok(payment.ToModel())
: Result.Fail<Payment>("Could not find payment for the order");
}
catch (Exception ex)
{
return Result.Fail<Payment>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Refund>> GetRefundAsync(long refundId, CancellationToken cancellationToken = default) public async ValueTask<Result<Refund>> GetRefundAsync(long refundId, CancellationToken cancellationToken = default)
{ {
try try
@@ -95,6 +116,25 @@ public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> cont
} }
} }
public async ValueTask<Result<bool>> HasLedgerEntryAsync(long orderId, long paymentId, long gatewayId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var exists = await context.Ledger.AnyAsync(l =>
l.OrderId == orderId &&
l.PaymentId == paymentId &&
l.PaymentGatewayId == gatewayId, cancellationToken);
return Result.Ok(exists);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> WriteLedgerEntryAsync(CreateLedgerEntry request, CancellationToken cancellationToken = default) public async ValueTask<Result> WriteLedgerEntryAsync(CreateLedgerEntry request, CancellationToken cancellationToken = default)
{ {
try try
@@ -28,7 +28,7 @@ public class LeadService(IDbContextFactory<ShopDbContext> contextFactory)
FeedItemId = request.FeedItemId, FeedItemId = request.FeedItemId,
Status = LeadStatus.New, Status = LeadStatus.New,
TargetId = request.TargetId, TargetId = request.TargetId,
AttributionHash = HashService.StringToSha256Hash.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}") AttributionHash = HashService.StringToSha256Hash($"{request.ClickId}{request.AppClickId}{request.WebClickId}")
}); });
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
@@ -1,4 +1,6 @@
using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Models;
using static LiteCharms.Features.Extensions.Hash;
namespace LiteCharms.Features.Tests; namespace LiteCharms.Features.Tests;
@@ -65,17 +67,18 @@ public class HashServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{ {
var paymentId = hashService.HashEncodeLongId(1001).Value; var paymentId = hashService.HashEncodeLongId(1001).Value;
var incomingForm = new Dictionary<string, string> var payload = new PayfastWebhookPayload
{ {
{ "m_payment_id", paymentId }, Amount = "350.00",
{ "amount", "350.00" }, ItemName = "System Architecture Book",
{ "item_name", "System Architecture Book" } MPaymentId = paymentId,
}; };
var rawPayload = $"amount=350.00&item_name=System+Architecture+Book&m_payment_id={paymentId}&passphrase={payfastPassphrase}"; var rawPayload = payload.ToRawPayfastPayload(payfastPassphrase);
var generatedSignature = HashService.ToMd5Hash(rawPayload).Value; var generatedSignature = HashService.ToMd5Hash(rawPayload).Value;
var result = hashService.VerifyPayfastWebhookSignature(incomingForm, generatedSignature); var result = hashService.VerifyPayfastWebhookSignature(payload, generatedSignature);
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.True(result.Value); Assert.True(result.Value);
+79
View File
@@ -1,5 +1,6 @@
using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Hasher.Configuration; using LiteCharms.Features.Hasher.Configuration;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.Extensions; namespace LiteCharms.Features.Extensions;
@@ -20,4 +21,82 @@ public static class Hash
return services; return services;
} }
public static string ToRawPayfastPayload(this PayfastWebhookPayload input, string passphrase)
{
var parameters = new List<string>();
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);
}
} }
+16 -9
View File
@@ -1,5 +1,6 @@
using LiteCharms.Features.Abstractions; using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Hasher.Configuration; using LiteCharms.Features.Hasher.Configuration;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.Hasher; namespace LiteCharms.Features.Hasher;
@@ -40,24 +41,30 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant());
} }
public Result<bool> VerifyPayfastWebhookSignature(IDictionary<string, string> incomingFormData, string incomingSignature) public Result<bool> VerifyPayfastWebhookSignature(PayfastWebhookPayload payload, string incomingSignature)
{ {
try try
{ {
if (string.IsNullOrWhiteSpace(incomingSignature)) if (string.IsNullOrWhiteSpace(incomingSignature))
return Result.Fail<bool>("Validation failed: Missing signature string parameter."); return Result.Fail<bool>("Validation failed: Missing signature string parameter.");
var sortedFields = incomingFormData var parameters = new List<string>();
.Where(field => !string.Equals(field.Key, "signature", StringComparison.OrdinalIgnoreCase))
.OrderBy(field => field.Key, StringComparer.Ordinal)
.Select(field => $"{field.Key}={WebUtility.UrlEncode(field.Value)}");
string payload = string.Join("&", sortedFields); 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)) if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase))
payload += $"&passphrase={WebUtility.UrlEncode(settings.PayfastPassphrase)}"; signatureString += $"&passphrase={WebUtility.UrlEncode(settings.PayfastPassphrase)}";
var localHashResult = ToMd5Hash(payload); var localHashResult = ToMd5Hash(signatureString);
if (!localHashResult.IsSuccess) if (!localHashResult.IsSuccess)
return Result.Fail<bool>(localHashResult.Errors); return Result.Fail<bool>(localHashResult.Errors);
@@ -68,7 +75,7 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Fail<bool>(new Error("An error occurred during MD5 verification loop.").CausedBy(ex)); return Result.Fail<bool>(new Error("An error occurred during Payfast MD5 verification.").CausedBy(ex));
} }
} }
@@ -147,6 +147,8 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Microsoft.IdentityModel.Tokens" />
<Using Include="Microsoft.AspNetCore.Http" />
<Using Include="HashidsNet" /> <Using Include="HashidsNet" />
<Using Include="System.Net" /> <Using Include="System.Net" />
<Using Include="System.Text.RegularExpressions" /> <Using Include="System.Text.RegularExpressions" />
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Models;
public sealed class PayfastWebhookPayload
{
public string? Amount { get; set; }
public string? ItemName { get; set; }
public string? MPaymentId { get; set; }
}