Compare commits

...
3 Commits
21 changed files with 757 additions and 24 deletions
@@ -0,0 +1,98 @@
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 PaymentServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly PaymentService paymentService = fixture.Services.GetRequiredService<PaymentService>();
[IntegrationFact]
public async Task CreateRefundAsync_ShouldReturn_ResultWithRefundId()
{
var request = new CreateRefund
{
Amount = 50,
OrderId = 2,
Type = RefundTypes.Partial,
Reason = "Returned damaged book",
Status = RefundStatus.Completed,
};
var result = await paymentService.CreateRefundAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithSuccess()
{
var request = new CreateLedgerEntry
{
CustomerId = 1,
OrderId = 1,
PaymentGatewayId = 1,
PaymentGatewayReference = "TEST REFERENCE",
PaymentId = 1,
Status = LedgerStatuses.Received,
};
var result = await paymentService.WriteLedgerEntryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetPaymentGatewayAsync_ShouldReturn_ResultWithPaymentGateway()
{
var result = await paymentService.GetPaymentGatewayAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task CreatePaymentGatewayAsync_ShouldReturn_ResultWithGatewayId()
{
var request = new CreatePaymentGateway
{
IsSandbox = true,
MerchantId = "10049307",
MerchantKey = "ju6navn0jcbf0",
Name = "Payfast",
Website = "https://sandbox.payfast.co.za/eng/process",
};
var result = await paymentService.CreatePaymentGatewayAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task CompletePaymentAsync_ShouldReturn_ResultWithSuccess()
{
var result = await paymentService.CompletePaymentAsync(1, PaymentStatuses.Paid, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdatePaymentAsync_ShouldReturn_ResultWithSuccess()
{
var result = await paymentService.UpdatePaymentAsync(1, 200, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task CreatePaymentAsync_ShouldReturn_ResultWithPaymentId()
{
var result = await paymentService.CreatePaymentAsync(100, 1, "HASHEDID", fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
}
@@ -9,6 +9,47 @@ public class ProductServiceFeatureTests(Fixture fixture, ITestOutputHelper outpu
{
private readonly ProductService productService = fixture.Services.GetRequiredService<ProductService>();
[IntegrationFact]
public async Task CheckProductStockAvailabilityAsync_ShouldReturn_ResultWithProductInventory()
{
var result = await productService.CheckProductStockAvailabilityAsync(1, 1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task ReserveProductInventoryAsync_ShouldReturn_ResultWithSuccess()
{
var request = new ReserveStock
{
ProductId = 1,
ProductPriceId = 1,
Reservation = 100,
};
var result = await productService.ReserveProductInventoryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task AllocateProductInventoryAsync_ShouldReturn_ResultWithSuccess()
{
var request = new AllocateStock
{
ProductId = 1,
ProductPriceId = 1,
Allocation = 500,
};
var result = await productService.AllocateProductInventoryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task AddProductCategoryAsync_ShouldReturn_ResultWithId()
{
@@ -1,3 +0,0 @@
namespace LiteCharms.Features.MidrandBooks.Abstractions;
public interface IService;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Categories.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -6,11 +6,24 @@ 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 Refund ToModel(this Payments.Entities.Refund entity) => new()
{
CreatedAt = entity.CreatedAt,
Amount = entity.Amount,
Id = entity.Id,
OrderId = entity.OrderId,
Reason = entity.Reason,
Status = entity.Status,
Type = entity.Type,
UpdatedAt = entity.UpdatedAt,
};
public static PaymentLedger ToModel(this Payments.Entities.PaymentLedger entity) => new()
{
Id = entity.Id,
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
namespace LiteCharms.Features.MidrandBooks.Extensions;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Pages.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -0,0 +1,53 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed record UpdateRefund
{
public long OrderId { get; set; }
public RefundStatus Status { get; set; }
public string? Reason { get; set; }
public decimal Amount { get; set; }
};
public sealed record CreateRefund
{
public long OrderId { get; set; }
public RefundTypes Type { get; set; }
public RefundStatus Status { get; set; }
public string? Reason { get; set; }
public decimal Amount { get; set; }
}
public sealed record CreateLedgerEntry
{
public required LedgerStatuses Status { get; set; }
public required long OrderId { get; set; }
public required long PaymentId { get; set; }
public required long CustomerId { get; set; }
public string? PaymentGatewayReference { get; set; }
public long? PaymentGatewayId { get; set; }
}
public sealed record CreatePaymentGateway
{
public required string? Name { get; set; }
public string? Website { get; set; }
public required string? MerchantId { get; set; }
public required string? MerchantKey { get; set; }
public bool IsSandbox { get; set; }
}
@@ -1,7 +1,265 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed class PaymentService : IService
public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result<Refund>> GetRefundAsync(long refundId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var refund = await context.Refunds.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == refundId, cancellationToken);
return refund is not null
? Result.Ok(refund.ToModel())
: Result.Fail<Refund>("Could not find refund");
}
catch (Exception ex)
{
return Result.Fail<Refund>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdateRefundAsync(long refundId, UpdateRefund 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("Order not found");
var updatedRows = await context.Refunds
.Where(r => r.Id == refundId && r.OrderId == request.OrderId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(r => r.Status, request.Status)
.SetProperty(r => r.Reason, request.Reason)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow)
.SetProperty(r => r.Amount, request.Amount), cancellationToken);
return updatedRows > 0
? Result.Ok()
: Result.Fail("Failed to update refund");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreateRefundAsync(CreateRefund request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == request.OrderId
&& o.Status == OrderStatus.Completed, cancellationToken);
if (order is null) return Result.Fail("Order not found");
if (request.Amount > order.Total)
return Result.Fail<long>("Refund amount cannot be greater than order total");
var totalRefundsPaid = await context.Refunds
.Where(r => r.OrderId == request.OrderId)
.SumAsync(r => r.Amount, cancellationToken);
if (request.Amount > (order.Total - totalRefundsPaid))
return Result.Fail<long>("Refund amount exceeds amount available for refund");
var refund = context.Refunds.Add(new Entities.Refund
{
Amount = request.Amount,
CreatedAt = DateTime.UtcNow,
OrderId = request.OrderId,
Reason = request.Reason,
Status = request.Status,
Type = request.Type,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(refund.Entity.Id)
: Result.Fail<long>("Failed to create refund");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> WriteLedgerEntryAsync(CreateLedgerEntry 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("Order not found");
if (!await context.Customers.AnyAsync(o => o.Id == request.CustomerId, cancellationToken))
return Result.Fail("Customer not found");
if (!await context.Orders.AnyAsync(oc => oc.Id == request.OrderId && oc.CustomerId == request.CustomerId, cancellationToken))
return Result.Fail("Customer does not match the order");
if (!await context.Payments.AnyAsync(o => o.Id == request.PaymentId && o.OrderId == request.OrderId, cancellationToken))
return Result.Fail("Payment not found");
if (request.PaymentGatewayId is not null)
if (!await context.Gateways.AnyAsync(o => o.Id == request.PaymentGatewayId, cancellationToken))
return Result.Fail("Gateway not found");
context.Ledger.Add(new Entities.PaymentLedger
{
CreatedAt = DateTime.UtcNow,
CustomerId = request.CustomerId,
OrderId = request.OrderId,
PaymentGatewayId = request.PaymentGatewayId,
PaymentGatewayReference = request.PaymentGatewayReference,
PaymentId = request.PaymentId,
Status = request.Status,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create ledger entry");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<PaymentGateway>> GetPaymentGatewayAsync(long paymentGatewayId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var gateway = await context.Gateways.AsNoTracking().FirstOrDefaultAsync(g => g.Id == paymentGatewayId, cancellationToken);
return gateway is not null
? Result.Ok(gateway.ToModel())
: Result.Fail<PaymentGateway>("Could not find gateway");
}
catch (Exception ex)
{
return Result.Fail<PaymentGateway>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreatePaymentGatewayAsync(CreatePaymentGateway request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Gateways.AnyAsync(g => g.MerchantId == request.MerchantId && g.MerchantKey == request.MerchantKey, cancellationToken))
return Result.Fail<long>("A gateway with the same credentials already exists");
var gateway = context.Gateways.Add(new Entities.PaymentGateway
{
CreatedAt = DateTime.UtcNow,
Enabled = true,
IsSandbox = request.IsSandbox,
MerchantId = request.MerchantId,
MerchantKey = request.MerchantKey,
Name = request.Name,
Website = request.Website,
Passphrase = "N/A",
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(gateway.Entity.Id)
: Result.Fail<long>("Failed to create payment gateway");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CompletePaymentAsync(long paymentId, PaymentStatuses status, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (status == PaymentStatuses.NotPaid)
return Result.Fail("Cannot finalise a payment using NotPaid status");
var updatedRecords = await context.Payments
.Where(p => p.Id == paymentId && p.Status != PaymentStatuses.Paid && p.Status != status)
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.Status, status)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow), cancellationToken);
return updatedRecords > 0
? Result.Ok()
: Result.Fail("Failed to update payment");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdatePaymentAsync(long paymentId, decimal amount, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var updatedRecords = await context.Payments
.Where(p => p.Id == paymentId && p.Status == PaymentStatuses.NotPaid)
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.Amount, amount)
.SetProperty(u => u.Status, PaymentStatuses.NotPaid)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow), cancellationToken);
return updatedRecords > 0
? Result.Ok()
: Result.Fail("Failed to update payment");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreatePaymentAsync(decimal amount, long orderId, string reference, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Payments.AnyAsync(p => p.OrderId == orderId && p.Amount == amount && p.Status != PaymentStatuses.Paid, cancellationToken))
return Result.Fail<long>("An order with the same amount already exists in the system");
var payment = context.Payments.Add(new Entities.Payment
{
CreatedAt = DateTime.UtcNow,
Amount = amount,
OrderId = orderId,
Reference = reference,
Status = PaymentStatuses.NotPaid,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(payment.Entity.Id)
: Result.Fail<long>("Failed to make payment");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -2,6 +2,24 @@
namespace LiteCharms.Features.MidrandBooks.Products.Models;
public sealed record ReserveStock
{
public required long ProductId { get; set; }
public required long ProductPriceId { get; set; }
public int Reservation { get; set; }
}
public sealed record AllocateStock
{
public required long ProductId { get; set; }
public required long ProductPriceId { get; set; }
public int Allocation { get; set; }
}
public sealed record CreateProduct
{
public required ProductTypes Type { get; set; }
@@ -1,14 +1,132 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Categories.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
using LiteCharms.Features.MidrandBooks.Products.Models;
using LiteCharms.Features.Models;
using Org.BouncyCastle.Asn1.Ocsp;
namespace LiteCharms.Features.MidrandBooks.Products;
public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result<ProductInventory>> CheckProductStockAvailabilityAsync(long productId, long productPriceId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var inventory = await context.Inventories
.AsNoTracking()
.Where(i => i.ProductPriceId == productPriceId && i.ProductId == productId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
return inventory is not null
? Result.Ok(inventory.ToModel())
: Result.Fail<ProductInventory>("Product sold out");
}
catch (Exception ex)
{
return Result.Fail<ProductInventory>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> ReserveProductInventoryAsync(ReserveStock request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var oldInventory = await context.Inventories
.AsNoTracking()
.Where(i => i.ProductPriceId == request.ProductPriceId && i.ProductId == request.ProductId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
var newAllocation = 0;
var newReservation = 0;
if (oldInventory is not null)
{
newAllocation = oldInventory.TotalAllocated;
newReservation = oldInventory.TotalReserved + request.Reservation;
}
else
{
newAllocation = 0;
newReservation = request.Reservation;
}
if (newAllocation - newReservation < 0)
return Result.Fail<long>("Allocation failure: The requested book quantity exceeds current physical inventory availability.");
var inventory = context.Inventories.Add(new Entities.ProductInventory
{
CreatedAt = DateTime.UtcNow,
ProductId = request.ProductId,
ProductPriceId = request.ProductPriceId,
Status = InventoryStatuses.Reserved,
TotalAllocated = newAllocation,
TotalReserved = newReservation,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(inventory.Entity.Id)
: Result.Fail<long>("Failed to create inventory entry");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> AllocateProductInventoryAsync(AllocateStock request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var oldInventory = await context.Inventories
.AsNoTracking()
.Where(i => i.ProductPriceId == request.ProductPriceId && i.ProductId == request.ProductId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
var newAllocation = 0;
var newReservation = 0;
if (oldInventory is not null)
{
newAllocation = oldInventory.TotalAllocated + request.Allocation;
newReservation = oldInventory.TotalReserved;
}
else
{
newAllocation = request.Allocation;
newReservation = 0;
}
var inventory = context.Inventories.Add(new Entities.ProductInventory
{
CreatedAt = DateTime.UtcNow,
ProductId = request.ProductId,
ProductPriceId = request.ProductPriceId,
Status = InventoryStatuses.Adjustment,
TotalAllocated = newAllocation,
TotalReserved = newReservation,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(inventory.Entity.Id)
: Result.Fail<long>("Failed to create inventory entry");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> AddProductCategoryAsync(long productId, long categoryId, CancellationToken cancellationToken = default)
{
try
@@ -0,0 +1,3 @@
namespace LiteCharms.Features.Abstractions;
public interface IService;
+18 -8
View File
@@ -1,13 +1,23 @@
namespace LiteCharms.Features.Extensions;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.Hasher.Configuration;
namespace LiteCharms.Features.Extensions;
public static class Hash
{
public static readonly Func<string?, string?> StringToSha256Hash = (input) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!)));
public const string HasherConfigSectionName = "HasherSettings";
public static readonly Func<Stream, string?> StreamToSha256Hash = (stream) =>
Convert.ToHexString(SHA256.HashData(stream));
public static IServiceCollection AddHashServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<HasherSettings>(configuration.GetSection(HasherConfigSectionName));
public static readonly Func<byte[], string?> BytesToSha256Hash = (bytes) =>
Convert.ToHexString(SHA256.HashData(bytes));
}
var settings = configuration.GetSection(HasherConfigSectionName).Get<HasherSettings>();
services.AddSingleton<IHashids>(_ =>
new Hashids(settings!.Salt, minHashLength: settings.MinHashLength));
services.AddSingleton<HashService>();
return services;
}
}
@@ -0,0 +1,10 @@
namespace LiteCharms.Features.Hasher.Configuration;
public sealed class HasherSettings
{
public string? Salt { get; set; }
public int MinHashLength { get; set; }
public string? PayfastPassphrase { get; set; }
}
+112
View File
@@ -0,0 +1,112 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Hasher.Configuration;
namespace LiteCharms.Features.Hasher;
public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings> options) : IService
{
private readonly HasherSettings settings = options.Value;
[System.Text.RegularExpressions.GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")]
private static partial System.Text.RegularExpressions.Regex HexHashRegex();
public static readonly Func<string?, string?> StringToSha256Hash = (input) =>
string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
public static readonly Func<Stream, string?> StreamToSha256Hash = (stream) =>
stream is null ? null : Convert.ToHexString(SHA256.HashData(stream));
public static readonly Func<byte[], string?> BytesToSha256Hash = (bytes) =>
bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes));
public static Result<string> ComputeMd5Hash(string input)
{
if (string.IsNullOrEmpty(input))
return Result.Fail<string>("Input content cannot be null or empty for MD5 processing.");
byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant());
}
public Result<bool> VerifyPayfastWebhookSignature(IDictionary<string, string> incomingFormData, string incomingSignature)
{
try
{
if (string.IsNullOrWhiteSpace(incomingSignature))
return Result.Fail<bool>("Validation failed: Missing signature string parameter.");
var sortedFields = incomingFormData
.Where(field => field.Key != "signature")
.OrderBy(field => field.Key)
.Select(field => $"{field.Key}={Uri.EscapeDataString(field.Value).Replace("%20", "+")}");
string payload = string.Join("&", sortedFields);
if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase))
payload += $"&passphrase={Uri.EscapeDataString(settings.PayfastPassphrase).Replace("%20", "+")}";
var localHashResult = ComputeMd5Hash(payload);
if (!localHashResult.IsSuccess)
return Result.Fail<bool>(localHashResult.Errors);
bool isValid = string.Equals(localHashResult.Value, incomingSignature, StringComparison.OrdinalIgnoreCase);
return Result.Ok(isValid);
}
catch (Exception ex)
{
return Result.Fail<bool>(new Error("An error occurred during MD5 verification loop.").CausedBy(ex));
}
}
public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex().IsMatch(input)
? Result.Fail<string>("Input must be a valid hexadecimal string.")
: Result.Ok(hasher.EncodeHex(input));
public Result<string> HashEncodeIntId(int id) => id < 0
? Result.Fail<string>("Id cannot be negative.")
: Result.Ok(hasher.Encode(id));
public Result<string> HashEncodeLongId(long id) => id < 0
? Result.Fail<string>("Id cannot be negative.")
: Result.Ok(hasher.EncodeLong(id));
public Result<int> DecodeIntIdHash(string hash)
{
if (string.IsNullOrWhiteSpace(hash)) return Result.Fail<int>("Invalid token layout.");
int[] decoded = hasher.Decode(hash);
return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail<int>("Invalid or modified Int hash token.");
}
public Result<long> DecodeLongIdHash(string hash)
{
if (string.IsNullOrWhiteSpace(hash)) return Result.Fail<long>("Invalid token layout.");
long[] decoded = hasher.DecodeLong(hash);
return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail<long>("Invalid or modified Long hash token.");
}
public Result<string> DecodeHexHash(string hex)
{
try
{
string decoded = hasher.DecodeHex(hex);
return string.IsNullOrEmpty(decoded)
? Result.Fail<string>("Invalid or corrupted hex hash.")
: Result.Ok(decoded);
}
catch (FormatException fex)
{
return Result.Fail<string>(new Error("Invalid hash structure.").CausedBy(fex));
}
catch (Exception ex)
{
return Result.Fail<string>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -31,6 +31,7 @@
<!-- Quartz Scheduler-->
<ItemGroup>
<PackageReference Include="Hashids.net" Version="1.7.0" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.96">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -146,6 +147,7 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="HashidsNet" />
<Using Include="System.Globalization" />
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" />
@@ -1,4 +1,4 @@
using static LiteCharms.Features.Extensions.Hash;
using LiteCharms.Features.Hasher;
namespace LiteCharms.Features.S3.Abstractions;
@@ -26,7 +26,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
stream.Seek(0, SeekOrigin.Begin);
var fileHash = StreamToSha256Hash(stream);
var fileHash = HashService.StreamToSha256Hash(stream);
if(string.IsNullOrWhiteSpace(fileHash))
return Result.Fail<string>("Failed to compute file hash.");
@@ -39,7 +39,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
Key = fileKey,
InputStream = stream,
ContentType = contentType,
UseChunkEncoding = false
UseChunkEncoding = false,
};
stream.Seek(0, SeekOrigin.Begin);