Compare commits

..

10 Commits

Author SHA1 Message Date
khwezi 2ed15b548f Merge pull request 'Refactored PayfastService.GenerateSignature()' (#104) from payments into master
Reviewed-on: #104
2026-06-12 23:27:21 +02:00
Khwezi Mngoma 7d2bc7f1f2 Refactored PayfastService.GenerateSignature()
continuous-integration/drone/pr Build is passing
2026-06-12 23:26:54 +02:00
khwezi ef2428f8e3 Merge pull request 'Refactored GenerateSignature' (#103) from payments into master
Reviewed-on: #103
2026-06-12 23:20:08 +02:00
Khwezi Mngoma 5edff5e272 Refactored GenerateSignature
continuous-integration/drone/pr Build is passing
2026-06-12 23:19:40 +02:00
khwezi b424b24c2e Merge pull request 'Changed optional fields on Customer entity' (#102) from payments into master
Reviewed-on: #102
2026-06-12 23:02:02 +02:00
Khwezi Mngoma 310c1237b1 Changed optional fields on Customer entity
continuous-integration/drone/pr Build is passing
2026-06-12 23:00:57 +02:00
khwezi cadc5888cc Merge pull request 'Added new service methods' (#101) from payments into master
Reviewed-on: #101
2026-06-12 22:09:17 +02:00
Khwezi Mngoma 618e57074a Added new service methods
continuous-integration/drone/pr Build is passing
2026-06-12 22:08:54 +02:00
khwezi 92abf6c5be Merge pull request 'Added GetBookByProductId()' (#100) from payments into master
Reviewed-on: #100
2026-06-12 21:58:03 +02:00
Khwezi Mngoma b60b8236af Added GetBookByProductId()
continuous-integration/drone/pr Build is passing
2026-06-12 21:57:34 +02:00
8 changed files with 1423 additions and 9 deletions
@@ -58,6 +58,30 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
}
}
public async ValueTask<Result<AuthorBook>> GetBookByProductIdAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var book = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product)
.ThenInclude(b => b!.Prices)
.Include(b => b.Pages)
.FirstOrDefaultAsync(b => b.ProductId == productId, cancellationToken);
return book is null
? Result.Fail<AuthorBook>(new Error($"Book with product ID {productId} not found"))
: Result.Ok(book.ToModel());
}
catch (Exception ex)
{
return Result.Fail<AuthorBook>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<AuthorBook>> GetBookAsync(long bookId, CancellationToken cancellationToken = default)
{
try
@@ -334,6 +334,28 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
}
}
public async ValueTask<Result<Customer>> GetCustomerAsync(string email, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customer = await context.Customers
.AsNoTracking()
.Include(c => c.Contacts)
.Include(c => c.Addresses)
.FirstOrDefaultAsync(c => c.Email == email, cancellationToken);
return customer is not null
? Result.Ok(customer.ToModel())
: Result.Fail<Customer>(new Error($"Customer with email '{email}' does not exist."));
}
catch (Exception ex)
{
return Result.Fail<Customer>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Customer>> GetCustomerAsync(long customerId, CancellationToken cancellationToken = default)
{
try
@@ -12,8 +12,8 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
builder.Property(c => c.Company).IsRequired(false);
builder.Property(c => c.VatNumber).IsRequired(false);
builder.Property(c => c.Email).IsRequired();
builder.Property(c => c.Phone).IsRequired();
builder.Property(c => c.Website).IsRequired();
builder.Property(c => c.Phone).IsRequired(false);
builder.Property(c => c.Website).IsRequired(false);
builder.Property(c => c.Enabled).HasDefaultValue(true);
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); });
@@ -164,6 +164,27 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
public async ValueTask<Result> CancelOrderAsync(long orderId, CancellationToken cancellationToken = default) =>
await UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, cancellationToken);
public async ValueTask<Result<Order>> GetPendingOrderAsync(long customerId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.AsNoTracking()
.Where(o => o.Status == OrderStatus.Pending && o.CustomerId == customerId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
return order is not null
? Result.Ok(order.ToModel())
: Result.Fail<Order>("Order not found.");
}
catch (Exception ex)
{
return Result.Fail<Order>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Order>> GetOrderAsync(long orderId, CancellationToken cancellationToken = default)
{
try
@@ -152,11 +152,16 @@ public sealed partial class PayfastService(IDbContextFactory<MidrandBooksDbConte
if (string.IsNullOrEmpty(kvp.Value))
continue;
if (kvp.Key.Equals("signature", StringComparison.OrdinalIgnoreCase))
continue;
string key = kvp.Key;
// 1. Payfast requires spaces to be '+' signs. HttpUtility does this perfectly.
string encodedVal = HttpUtility.UrlEncode(kvp.Value.Trim());
string val = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToLowerInvariant());
// 2. Payfast requires all OTHER percent-encoded hex arrays to be UPPERCASE (e.g., %3A instead of %3a)
string val = Regex.Replace(encodedVal, "%[0-9A-Fa-f]{2}", m => m.Value.ToUpperInvariant());
pfOutput.Append($"{key}={val}&");
}
@@ -167,13 +172,13 @@ public sealed partial class PayfastService(IDbContextFactory<MidrandBooksDbConte
if (!string.IsNullOrWhiteSpace(passPhrase))
{
// Apply the exact same encoding rule to your passphrase
string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim());
string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToLowerInvariant());
string safePassphrase = Regex.Replace(encodedPassphrase, "%[0-9A-Fa-f]{2}", m => m.Value.ToUpperInvariant());
getString += $"&passphrase={safePassphrase}";
}
return HashService.ToMd5Hash(getString);
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class OnlyEmailIsMandatoryOnCustomer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Website",
table: "Customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Website",
table: "Customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}
@@ -17,7 +17,7 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -309,7 +309,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasDefaultValue(true);
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
@@ -321,7 +320,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("text");
b.Property<string>("Website")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");