Compare commits

..

10 Commits

Author SHA1 Message Date
khwezi 1ace61baa5 Merge pull request 'Honoring the mandatory field sequence' (#105) from payments into master
Reviewed-on: #105
2026-06-12 23:30:43 +02:00
Khwezi Mngoma e3e49b8db2 Honoring the mandatory field sequence
continuous-integration/drone/pr Build is passing
2026-06-12 23:30:13 +02:00
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
7 changed files with 1439 additions and 17 deletions
@@ -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) public async ValueTask<Result<Customer>> GetCustomerAsync(long customerId, CancellationToken cancellationToken = default)
{ {
try try
@@ -12,8 +12,8 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
builder.Property(c => c.Company).IsRequired(false); builder.Property(c => c.Company).IsRequired(false);
builder.Property(c => c.VatNumber).IsRequired(false); builder.Property(c => c.VatNumber).IsRequired(false);
builder.Property(c => c.Email).IsRequired(); builder.Property(c => c.Email).IsRequired();
builder.Property(c => c.Phone).IsRequired(); builder.Property(c => c.Phone).IsRequired(false);
builder.Property(c => c.Website).IsRequired(); builder.Property(c => c.Website).IsRequired(false);
builder.Property(c => c.Enabled).HasDefaultValue(true); builder.Property(c => c.Enabled).HasDefaultValue(true);
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); }); 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) => public async ValueTask<Result> CancelOrderAsync(long orderId, CancellationToken cancellationToken = default) =>
await UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, cancellationToken); 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) public async ValueTask<Result<Order>> GetOrderAsync(long orderId, CancellationToken cancellationToken = default)
{ {
try try
@@ -147,18 +147,56 @@ public sealed partial class PayfastService(IDbContextFactory<MidrandBooksDbConte
{ {
var pfOutput = new StringBuilder(); var pfOutput = new StringBuilder();
foreach (var kvp in data) // Define the exact structural sequence mandated by Payfast's documentation
string[] mandatorySequence =
[
"merchant_id",
"merchant_key",
"return_url",
"cancel_url",
"notify_url",
"name_first",
"name_last",
"email_address",
"cell_number",
"m_payment_id",
"amount",
"item_name",
"item_description",
"custom_int1",
"custom_int2",
"custom_int3",
"custom_int4",
"custom_int5",
"custom_str1",
"custom_str2",
"custom_str3",
"custom_str4",
"custom_str5",
"email_confirmation",
"confirmation_address",
"payment_method",
"subscription_type",
"billing_date",
"recurring_amount",
"frequency",
"cycles"
];
// 1. Iterate explicitly by the mandatory positional array sequence instead of the dictionary's internal order
foreach (string key in mandatorySequence)
{ {
if (string.IsNullOrEmpty(kvp.Value)) // Only append if the key exists in your source dictionary and contains data
continue; if (data.TryGetValue(key, out string? rawValue) && !string.IsNullOrEmpty(rawValue))
{
// Payfast requires spaces to be '+' signs. HttpUtility does this natively.
string encodedVal = HttpUtility.UrlEncode(rawValue.Trim());
string key = kvp.Key; // 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());
string encodedVal = HttpUtility.UrlEncode(kvp.Value.Trim()); pfOutput.Append($"{key}={val}&");
}
string val = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToLowerInvariant());
pfOutput.Append($"{key}={val}&");
} }
string getString = pfOutput.Length > 0 string getString = pfOutput.Length > 0
@@ -168,8 +206,7 @@ public sealed partial class PayfastService(IDbContextFactory<MidrandBooksDbConte
if (!string.IsNullOrWhiteSpace(passPhrase)) if (!string.IsNullOrWhiteSpace(passPhrase))
{ {
string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim()); string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim());
string safePassphrase = Regex.Replace(encodedPassphrase, "%[0-9A-Fa-f]{2}", m => m.Value.ToUpperInvariant());
string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToLowerInvariant());
getString += $"&passphrase={safePassphrase}"; getString += $"&passphrase={safePassphrase}";
} }
@@ -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 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -309,7 +309,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
@@ -321,7 +320,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Website") b.Property<string>("Website")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");