payments #53
@@ -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>();
|
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]
|
[IntegrationFact]
|
||||||
public async Task AddProductCategoryAsync_ShouldReturn_ResultWithId()
|
public async Task AddProductCategoryAsync_ShouldReturn_ResultWithId()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,24 @@ using LiteCharms.Features.MidrandBooks.Orders.Models;
|
|||||||
using LiteCharms.Features.MidrandBooks.Pages.Models;
|
using LiteCharms.Features.MidrandBooks.Pages.Models;
|
||||||
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
using LiteCharms.Features.MidrandBooks.Products.Models;
|
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
namespace LiteCharms.Features.MidrandBooks.Extensions;
|
namespace LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
|
||||||
public static class Mappers
|
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()
|
public static PaymentLedger ToModel(this Payments.Entities.PaymentLedger entity) => new()
|
||||||
{
|
{
|
||||||
Id = entity.Id,
|
Id = entity.Id,
|
||||||
|
|||||||
@@ -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.MidrandBooks.Abstractions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Extensions;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||||
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
|
|
||||||
namespace LiteCharms.Features.MidrandBooks.Payments;
|
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;
|
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 sealed record CreateProduct
|
||||||
{
|
{
|
||||||
public required ProductTypes Type { get; set; }
|
public required ProductTypes Type { get; set; }
|
||||||
|
|||||||
@@ -4,11 +4,129 @@ using LiteCharms.Features.MidrandBooks.Extensions;
|
|||||||
using LiteCharms.Features.MidrandBooks.Postgres;
|
using LiteCharms.Features.MidrandBooks.Postgres;
|
||||||
using LiteCharms.Features.MidrandBooks.Products.Models;
|
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||||
using LiteCharms.Features.Models;
|
using LiteCharms.Features.Models;
|
||||||
|
using Org.BouncyCastle.Asn1.Ocsp;
|
||||||
|
|
||||||
namespace LiteCharms.Features.MidrandBooks.Products;
|
namespace LiteCharms.Features.MidrandBooks.Products;
|
||||||
|
|
||||||
public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
|
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)
|
public async ValueTask<Result> AddProductCategoryAsync(long productId, long categoryId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user