From f88cc42a88a440b606a73a460fe8a09f81cf7da6 Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Sun, 31 May 2026 18:42:00 +0200 Subject: [PATCH] Completed payment service implementation --- .../PaymentServiceFeatureTests.cs | 98 +++++++ .../ProductServiceFeatureTests.cs | 41 +++ .../Extensions/Mappers.cs | 13 + .../Payments/Models/Records.cs | 53 ++++ .../Payments/PaymentService.cs | 260 +++++++++++++++++- .../Products/Models/Records.cs | 18 ++ .../Products/ProductService.cs | 118 ++++++++ 7 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 LiteCharms.Features.MidrandBooks.Tests/PaymentServiceFeatureTests.cs create mode 100644 LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs diff --git a/LiteCharms.Features.MidrandBooks.Tests/PaymentServiceFeatureTests.cs b/LiteCharms.Features.MidrandBooks.Tests/PaymentServiceFeatureTests.cs new file mode 100644 index 0000000..3681116 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks.Tests/PaymentServiceFeatureTests.cs @@ -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 +{ + private readonly PaymentService paymentService = fixture.Services.GetRequiredService(); + + [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); + } +} diff --git a/LiteCharms.Features.MidrandBooks.Tests/ProductServiceFeatureTests.cs b/LiteCharms.Features.MidrandBooks.Tests/ProductServiceFeatureTests.cs index d4e6e4c..827025f 100644 --- a/LiteCharms.Features.MidrandBooks.Tests/ProductServiceFeatureTests.cs +++ b/LiteCharms.Features.MidrandBooks.Tests/ProductServiceFeatureTests.cs @@ -9,6 +9,47 @@ public class ProductServiceFeatureTests(Fixture fixture, ITestOutputHelper outpu { private readonly ProductService productService = fixture.Services.GetRequiredService(); + [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() { diff --git a/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs b/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs index 0380370..e8a8612 100644 --- a/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs +++ b/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs @@ -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, diff --git a/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs b/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs new file mode 100644 index 0000000..2326397 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Models/Records.cs @@ -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; } +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs index ce3668a..5570fb8 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs @@ -1,7 +1,265 @@ using LiteCharms.Features.MidrandBooks.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 contextFactory) : IService { + public async ValueTask> 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("Could not find refund"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask 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> 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("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("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("Failed to create refund"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask 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> 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("Could not find gateway"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> 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("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("Failed to create payment gateway"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask 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 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> 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("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("Failed to make payment"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } } diff --git a/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs b/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs index 9c09551..0a5a588 100644 --- a/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs +++ b/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs @@ -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; } diff --git a/LiteCharms.Features.MidrandBooks/Products/ProductService.cs b/LiteCharms.Features.MidrandBooks/Products/ProductService.cs index de96fd8..1682a70 100644 --- a/LiteCharms.Features.MidrandBooks/Products/ProductService.cs +++ b/LiteCharms.Features.MidrandBooks/Products/ProductService.cs @@ -4,11 +4,129 @@ 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 contextFactory) : IService { + public async ValueTask> 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("Product sold out"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> 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("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("Failed to create inventory entry"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> 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("Failed to create inventory entry"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + public async ValueTask AddProductCategoryAsync(long productId, long categoryId, CancellationToken cancellationToken = default) { try