Compare commits

...

13 Commits

Author SHA1 Message Date
khwezi 867fad8584 Merge pull request 'cart' (#98) from cart into main
Reviewed-on: #98
2026-06-16 13:47:36 +02:00
Khwezi Mngoma 31423ea48d Solved double entry issue on order confirmation
continuous-integration/drone/pr Build is passing
2026-06-16 13:47:03 +02:00
Khwezi Mngoma 6ca781759f Stable payment and order process 2026-06-16 13:39:40 +02:00
khwezi 55d241e362 Merge pull request 'Docker image cache test' (#97) from cart into main
Reviewed-on: #97
2026-06-16 10:58:55 +02:00
Khwezi Mngoma cb03b91c6c Docker image cache test
continuous-integration/drone/pr Build is passing
2026-06-16 10:58:03 +02:00
khwezi 0da92cfb5a Merge pull request 'Specified buildkit inline caching' (#96) from cart into main
Reviewed-on: #96
2026-06-16 10:45:08 +02:00
Khwezi Mngoma b0ea5ea098 Specified buildkit inline caching
continuous-integration/drone/pr Build is passing
2026-06-16 10:44:27 +02:00
khwezi 30cde40d5b Merge pull request 'Added caching to docker build stage' (#95) from cart into main
Reviewed-on: #95
2026-06-16 10:33:09 +02:00
Khwezi Mngoma 32eeb24558 Added caching to docker build stage
continuous-integration/drone/pr Build is passing
2026-06-16 10:32:25 +02:00
khwezi 5abe7a1476 Merge pull request 'ensured that untranslatable claims do not crash signalr' (#94) from cart into main
Reviewed-on: #94
2026-06-16 00:16:02 +02:00
Khwezi Mngoma bead8314da ensured that untranslatable claims do not crash signalr
continuous-integration/drone/pr Build is passing
2026-06-16 00:14:45 +02:00
khwezi ace7eeef8e Merge pull request 'Added automati revision history pruning' (#93) from cart into main
Reviewed-on: #93
2026-06-15 23:28:53 +02:00
Khwezi Mngoma 5c3ceeeb83 Added automati revision history pruning
continuous-integration/drone/pr Build is passing
2026-06-15 23:27:23 +02:00
9 changed files with 187 additions and 38 deletions
+5 -1
View File
@@ -23,7 +23,7 @@ trigger:
kind: pipeline kind: pipeline
type: docker type: docker
name: package name: package
steps: steps:
- name: docker-build - name: docker-build
image: plugins/docker image: plugins/docker
@@ -31,6 +31,10 @@ steps:
registry: nexus.khongisa.co.za registry: nexus.khongisa.co.za
repo: nexus.khongisa.co.za/midrandbooks repo: nexus.khongisa.co.za/midrandbooks
tags: [ latest, "1.${DRONE_BUILD_NUMBER}" ] tags: [ latest, "1.${DRONE_BUILD_NUMBER}" ]
use_cache: true
cache_from: nexus.khongisa.co.za/midrandbooks:latest
build_args:
- BUILDKIT_INLINE_CACHE=1
custom_labels: custom_labels:
- org.opencontainers.image.source=https://gitea.khongisa.co.za/litecharms/midrandbooks - org.opencontainers.image.source=https://gitea.khongisa.co.za/litecharms/midrandbooks
- org.opencontainers.image.version=1.${DRONE_BUILD_NUMBER} - org.opencontainers.image.version=1.${DRONE_BUILD_NUMBER}
@@ -19,7 +19,6 @@ public partial class Checkout()
[Inject] public IOptions<PayfastSettings> PayfastOptions { get; set; } = default!; [Inject] public IOptions<PayfastSettings> PayfastOptions { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!; [Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] public IJSRuntime JSRuntime { get; set; } = default!; [Inject] public IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private HydrationService HydrationService { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!; [Inject] private CancellationToken CancellationToken { get; set; } = default!;
private Cart ShoppingCart => CartService.ShoppingCart; private Cart ShoppingCart => CartService.ShoppingCart;
@@ -38,19 +37,6 @@ public partial class Checkout()
Navigation.LocationChanged += OnLocationChanged; Navigation.LocationChanged += OnLocationChanged;
CartService.OnCartChanged += CartService_OnCartChanged; CartService.OnCartChanged += CartService_OnCartChanged;
await CartService.LoadCartFromStorageAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender == false && HydrationService.CartHydrated == false)
{
await HydrationService.EnsureCustomerExistsAsync(CancellationToken);
await HydrationService.RehydrateCartFromPendingOrderAsync(CancellationToken);
CartService.NotifyStateChanged();
}
} }
private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged); private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged);
@@ -117,13 +103,26 @@ public partial class Checkout()
var orderHash = HashService.HashEncodeLongId(orderId).Value; var orderHash = HashService.HashEncodeLongId(orderId).Value;
var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken); var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken);
long paymentId = 0;
if (paymentGen.IsSuccess) paymentId = paymentGen.Value;
if(paymentGen.IsFailed)
{
var paymentFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentFetch.IsFailed) return;
paymentId = paymentFetch.Value.Id;
}
CreateLedgerEntry ledgerRequest = new() CreateLedgerEntry ledgerRequest = new()
{ {
OrderId = orderId, OrderId = orderId,
CustomerId = customerId, CustomerId = customerId,
PaymentGatewayId = 1, // TODO: lookup value to match user selection PaymentGatewayId = 1,
PaymentGatewayReference = orderHash, PaymentGatewayReference = orderHash,
PaymentId = paymentGen.Value, PaymentId = paymentId,
Status = LiteCharms.Features.LedgerStatuses.Sent, Status = LiteCharms.Features.LedgerStatuses.Sent,
}; };
await PaymentService.WriteLedgerEntryAsync(ledgerRequest, CancellationToken); await PaymentService.WriteLedgerEntryAsync(ledgerRequest, CancellationToken);
@@ -135,8 +134,8 @@ public partial class Checkout()
{ {
{ "merchant_id", PayfastOptions.Value.MerchantId! }, { "merchant_id", PayfastOptions.Value.MerchantId! },
{ "merchant_key", PayfastOptions.Value.MerchantKey! }, { "merchant_key", PayfastOptions.Value.MerchantKey! },
{ "return_url", $"{hostAddress}/payment-success" }, { "return_url", $"{hostAddress}/payment-success?reference={orderHash}" },
{ "cancel_url", $"{hostAddress}/payment-failed" }, { "cancel_url", $"{hostAddress}/payment-failed?reference={orderHash}" },
{ "notify_url", "https://api.uat.midrandbooks.co.za/v1/payments/payfast/confirm" }, { "notify_url", "https://api.uat.midrandbooks.co.za/v1/payments/payfast/confirm" },
{ "email_address", User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value! }, { "email_address", User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value! },
{ "m_payment_id", orderHash }, { "m_payment_id", orderHash },
@@ -99,14 +99,18 @@ public partial class Home : ComponentBase
private bool HasMoreItems => FilteredData.Count() > VisibleCount; private bool HasMoreItems => FilteredData.Count() > VisibleCount;
protected override async Task OnInitializedAsync() => await CartService.LoadCartFromStorageAsync(); protected override async Task OnInitializedAsync()
{
if (CartService.ShoppingCart.Items.Count == 0)
await CartService.LoadCartFromStorageAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender == false && HydrationService.CartHydrated == false) if (firstRender == false && HydrationService.CartHydrated == false)
{ {
await HydrationService.EnsureCustomerExistsAsync(CancellationToken); if(!CartService.ShoppingCart.CustomerId.HasValue)
await HydrationService.RehydrateCartFromPendingOrderAsync(CancellationToken); await HydrationService.EnsureCustomerExistsAsync(CancellationToken);
} }
} }
@@ -1,5 +1,6 @@
@page "/payment-failed" @page "/payment-failed"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject NavigationManager Navigation
@attribute [Authorize] @attribute [Authorize]
<div class="container py-5"> <div class="container py-5">
@@ -13,18 +14,16 @@
<line x1="12" y1="16" x2="12.01" y2="16"></line> <line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg> </svg>
</div> </div>
<h1 class="fw-bold mb-3">Payment Failed</h1> <h1 class="fw-bold mb-3">Payment Cancelled</h1>
<p class="text-muted fs-5">We couldn't process your transaction. Don't worry, no money was deducted from your account, and your cart items are safe.</p> <p class="text-muted fs-5">We couldn't process your transaction. Don't worry, no money was deducted from your account.</p>
<div class="bg-light p-3 rounded mt-4"> <div class="bg-light p-3 rounded mt-4">
<p class="mb-0 text-muted small text-uppercase fw-bold">Common Causes</p> <p class="mb-0 text-muted small text-uppercase fw-bold">Common Causes</p>
<p class="mb-0 fs-6 text-dark mt-1">Insufficient funds, incorrect card details, or a temporary bank gateway timeout.</p> <p class="mb-0 fs-6 text-dark mt-1">The order was cancelled / insufficient funds, incorrect card details, or a temporary bank gateway timeout.</p>
</div> </div>
</div> </div>
<div class="d-grid gap-3 mt-5"> <div class="d-grid gap-3 mt-5">
<a href="/checkout" class="btn btn-dark btn-lg rounded-pill py-3">Try Again</a>
<div class="row g-3"> <div class="row g-3">
<div class="col-6"> <div class="col-6">
<a href="/" class="btn btn-outline-dark w-100 rounded-pill py-3">View Store</a> <a href="/" class="btn btn-outline-dark w-100 rounded-pill py-3">View Store</a>
@@ -35,7 +34,7 @@
</div> </div>
</div> </div>
<p class="mt-5 text-muted small">If you noticed a charge or have any order questions, please contact our support desk with your account email <strong>user@email.com</strong>.</p> <p class="mt-5 text-muted small">If you noticed a charge or have any order questions, please contact our support desk with your account email <strong>shop@litecharms.co.za</strong>.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,72 @@
using LiteCharms.Features;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace MidrandBookshop.Components.Pages;
public partial class PaymentFailed
{
[Inject] public CartService CartService { get; set; } = default!;
[Inject] public OrderService OrderService { get; set; } = default!;
[Inject] private CustomerService CustomerService { get; set; } = default!;
[Inject] public PaymentService PaymentService { get; set; } = default!;
[Inject] public HashService HashService { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
private ClaimsPrincipal? User { get; set; }
[Parameter]
[SupplyParameterFromQuery(Name = "reference")]
public string? PaymentReference { get; set; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState!.User;
if (User?.Identity?.IsAuthenticated == false) Navigation.NavigateTo("/login");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
long orderId = HashService.DecodeLongIdHash(PaymentReference!).Value;
var customerEmail = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!;
var customerFetch = await CustomerService.GetCustomerAsync(customerEmail, CancellationToken);
if (customerFetch.IsFailed) return;
long customerId = customerFetch.Value.Id;
var orderUpdateResult = await OrderService.UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, CancellationToken);
if (orderUpdateResult.IsFailed) return;
var paymentIdFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentIdFetch.IsFailed) return;
await PaymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
CustomerId = customerId,
OrderId = orderId,
PaymentGatewayId = 1,
PaymentGatewayReference = PaymentReference,
PaymentId = paymentIdFetch.Value.Id,
Status = LedgerStatuses.Cancelled
}, CancellationToken);
CartService.Clear();
CartService.ShoppingCart.OrderId = null;
await CartService.SaveCartToStorageAsync();
CartService.NotifyStateChanged();
}
}
@@ -1,5 +1,6 @@
@page "/payment-success" @page "/payment-success"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject NavigationManager Navigation
@attribute [Authorize] @attribute [Authorize]
<div class="container py-5"> <div class="container py-5">
@@ -16,7 +17,7 @@
<p class="text-muted fs-5">Thank you for shopping with Midrand Books. Your order has been received and is being processed.</p> <p class="text-muted fs-5">Thank you for shopping with Midrand Books. Your order has been received and is being processed.</p>
<div class="bg-light p-3 rounded mt-4"> <div class="bg-light p-3 rounded mt-4">
<p class="mb-0 text-muted small text-uppercase fw-bold">Order Number</p> <p class="mb-0 text-muted small text-uppercase fw-bold">Order Number</p>
<h5 class="fw-bold mb-0">#MB-2026-8834</h5> <h5 class="fw-bold mb-0">@PaymentReference</h5>
</div> </div>
</div> </div>
@@ -27,12 +28,12 @@
<a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Order History</a> <a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Order History</a>
</div> </div>
<div class="col-6"> <div class="col-6">
<a href="/track-order" class="btn btn-outline-dark w-100 rounded-pill py-3">Track Order</a> <a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Track Order</a>
</div> </div>
</div> </div>
</div> </div>
<p class="mt-5 text-muted small">You will receive a confirmation email shortly at <strong>user@email.com</strong>.</p> <p class="mt-5 text-muted small">You will receive a confirmation email shortly at <strong>@CustomerEmail</strong>.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,69 @@
using LiteCharms.Features;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace MidrandBookshop.Components.Pages;
public partial class PaymentSuccess
{
[Inject] private CartService CartService { get; set; } = default!;
[Inject] private CustomerService CustomerService { get; set; } = default!;
[Inject] private PaymentService PaymentService { get; set; } = default!;
[Inject] private HashService HashService { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
[Parameter]
[SupplyParameterFromQuery(Name = "reference")]
public string? PaymentReference { get; set; }
private ClaimsPrincipal? User { get; set; }
private string? CustomerEmail { get; set; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState!.User;
if (User?.Identity?.IsAuthenticated == false) Navigation.NavigateTo("/login");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
long orderId = HashService.DecodeLongIdHash(PaymentReference!).Value;
string orderHash = HashService.HashEncodeLongId(orderId).Value;
CustomerEmail = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!;
var customerFetch = await CustomerService.GetCustomerAsync(CustomerEmail, CancellationToken);
if (customerFetch.IsFailed) return;
long customerId = customerFetch.Value.Id;
var paymentIdFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentIdFetch.IsFailed) return;
await PaymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
CustomerId = customerId,
OrderId = orderId,
PaymentGatewayId = 1,
PaymentGatewayReference = orderHash,
PaymentId = paymentIdFetch.Value.Id,
Status = LedgerStatuses.Changed
}, CancellationToken);
CartService.Clear();
CartService.ShoppingCart.OrderId = null;
await CartService.SaveCartToStorageAsync();
CartService.NotifyStateChanged();
}
}
+5 -5
View File
@@ -38,13 +38,13 @@ public sealed class HydrationService(AuthenticationStateProvider authStateProvid
if (existingCustomer.IsFailed) if (existingCustomer.IsFailed)
{ {
var name = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)!.Value!; var name = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? string.Empty;
var lastname = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)!.Value!; var lastname = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value ?? string.Empty;
var mobile = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.MobilePhone)!.Value!; var mobile = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.MobilePhone)?.Value ?? string.Empty;
var customerCreate = await customerService.CreateCustomerAsync(new CreateCustomer { Email = email }, cancellationToken); var customerCreate = await customerService.CreateCustomerAsync(new CreateCustomer { Email = email }, cancellationToken);
if (customerCreate.IsSuccess) if (customerCreate.IsSuccess && !string.IsNullOrWhiteSpace(name))
{ {
ShoppingCart.CustomerId = customerCreate.Value; ShoppingCart.CustomerId = customerCreate.Value;
@@ -65,7 +65,7 @@ public sealed class HydrationService(AuthenticationStateProvider authStateProvid
{ {
if (User?.Identity?.IsAuthenticated == false) return; if (User?.Identity?.IsAuthenticated == false) return;
if (ShoppingCart.OrderId > 0 && ShoppingCart.CustomerId > 0) if (ShoppingCart.OrderId.HasValue && ShoppingCart.CustomerId.HasValue)
{ {
cartService.CalculateTotalPrice(); cartService.CalculateTotalPrice();
CartHydrated = true; CartHydrated = true;
+2 -1
View File
@@ -48,7 +48,8 @@ metadata:
name: midrandbooks name: midrandbooks
namespace: midrandbooks-uat namespace: midrandbooks-uat
spec: spec:
replicas: 1 replicas: 2
revisionHistoryLimit: 0
selector: selector:
matchLabels: matchLabels:
app: midrandbooks app: midrandbooks