Quantity control

Adding the update quantity functionality

We will add another dialog component to adjust the quantity of stock of the products. This will take the updated quantity in stock and overwrite the previous value. Create another component in the admin feature folder by running the following command:

1ng g c features/admin/update-quantity --skip-tests

Open the update-quantity.component.ts and add the following code:

1import { Component, inject } from '@angular/core';
2import { FormsModule } from '@angular/forms';
3import { MatButtonModule } from '@angular/material/button';
4import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
5import { MatFormFieldModule, MatLabel } from '@angular/material/form-field';
6import { MatInputModule } from '@angular/material/input';
7
8@Component({
9  selector: 'app-update-quantity',
10  standalone: true,
11  imports: [
12    MatDialogModule,
13    MatFormFieldModule,
14    FormsModule,
15    MatLabel,
16    MatButtonModule,
17    MatInputModule
18  ],
19  templateUrl: './update-quantity.component.html',
20  styleUrl: './update-quantity.component.scss'
21})
22export class UpdateQuantityComponent {
23  data = inject(MAT_DIALOG_DATA);
24  updatedQuantity = this.data.quantity;
25  private dialogRef = inject(MatDialogRef<UpdateQuantityComponent>);
26
27  updateQuantity() {
28    if (this.updatedQuantity > 0) {
29      this.dialogRef.close({
30        updatedQuantity: this.updatedQuantity
31      })
32    }
33  }
34}
35

In this code we pass the existing quantity as a property of the data object we receive in the dialog component. We then have an updateQuantity() method that is called when the user submits the form. We are also using the FormsModule from Angular here to give us 2-way binding from the input in the dialog template to the component for the updatedQuantity property.

Next, update the template with this code:

1<h2 mat-dialog-title>Update quantity in stock</h2>
2
3<mat-dialog-content>
4    <div class="flex gap-4 mt-6">
5        <mat-form-field appearance="outline" class="flex">
6            <mat-label>Quantity of {{data.name}}</mat-label>
7            <input matInput min="0" [(ngModel)]="updatedQuantity" type="number">
8        </mat-form-field>
9        <button [disabled]="data.quantity === updatedQuantity" (click)="updateQuantity()" mat-flat-button
10            class="match-input-height">
11            Update quantity
12        </button>
13    </div>
14</mat-dialog-content>

This is pretty much the same code as the update quantity for the product-detail.component.html other than it is inside a <mat-dialog-content> component.

Next, create the method in the admin-catalog.component.ts to open this dialog and submit the updated quantity when the button is clicked.

1  openQuantityDialog(product: Product) {
2    const dialog = this.dialog.open(UpdateQuantityComponent, {
3      minWidth: '500px',
4      data: {
5        quantity: product.quantityInStock,
6        name: product.name
7      }
8    })
9    dialog.afterClosed().subscribe({
10      next: async result => {
11        if (result) {
12          console.log(result);
13          await firstValueFrom(this.adminService.updateStock(product.id, result.updatedQuantity));
14          const index = this.products.findIndex(p => p.id === product.id);
15          if (index !== -1) {
16            this.products[index].quantityInStock = result.updatedQuantity;
17          }
18        }
19      }
20    })
21  }

Then update the actions array in this same component to call this function upon button click:

1    {
2      label: 'Update quantity',
3      icon: 'add_circle',
4      tooltip: 'Update quantity in stock',
5      action: (row: any) => {
6        this.openQuantityDialog(row);
7      }
8    },

You should now be able to test this new functionality and update the quantity in stock for each product:

Image

Ensure the quantity is reflected in the table after the update.

Displaying the quantity on the product details page

Currently, we have not made use of the QuantityInStock property of the products in the app and regardless of what this actually is we allow users to order them anyway. Let’s first display this in the product details component. Open the product-details.component.html and add the following code just above the divider:

1<div class="flex items-center align-middle mb-3">
2    Quantity in stock: {{product.quantityInStock}}
3</div>
4
5<mat-divider></mat-divider>

This should give us:

Image

We won’t prevent the user from ordering more than in stock just yet as we want to test failure conditions if a user places an order over the stock amount.

Reduce the quantity when the order is placed

When a user places an order this is the point where we will reduce the stock. Open the OrdersController.cs in the API project and adjust the CreateOrder method with the following code:

1[InvalidateCache("api/products|")]
2[HttpPost]
3public async Task<ActionResult<Order>> CreateOrder(CreateOrderDto orderDto)
4{
5    var email = User.GetEmail();
6
7    var cart = await cartService.GetCartAsync(orderDto.CartId);
8
9    if (cart == null) return BadRequest("Cart not found");
10
11    if (cart.PaymentIntentId == null) return BadRequest("No payment intent for this order");
12
13    var items = new List<OrderItem>();
14
15    foreach (var item in cart.Items)
16    {
17        var productItem = await unit.Repository<Product>().GetByIdAsync(item.ProductId);
18
19        if (productItem == null) return BadRequest("Problem with the order");
20
21        if (productItem.QuantityInStock < item.Quantity)
22        {
23            return BadRequest($"Not enough stock for product {item.ProductName}. Available stock: {productItem.QuantityInStock}");
24        }
25
26        // Reduce the stock by the ordered quantity
27        productItem.QuantityInStock -= item.Quantity;
28
29        var itemOrdered = new ProductItemOrdered
30        {
31            ProductId = item.ProductId,
32            ProductName = item.ProductName,
33            PictureUrl = item.PictureUrl
34        };
35
36        var orderItem = new OrderItem
37        {
38            ItemOrdered = itemOrdered,
39            Price = productItem.Price,
40            Quantity = item.Quantity
41        };
42        items.Add(orderItem);
43
44        unit.Repository<Product>().Update(productItem);
45    }
46
47    var deliveryMethod = await unit.Repository<DeliveryMethod>().GetByIdAsync(orderDto.DeliveryMethodId);
48
49    if (deliveryMethod == null) return BadRequest("No delivery method selected");
50
51    var order = new Order
52    {
53        OrderItems = items,
54        DeliveryMethod = deliveryMethod,
55        ShippingAddress = orderDto.ShippingAddress,
56        Subtotal = items.Sum(x => x.Price * x.Quantity),
57        Discount = orderDto.Discount,
58        PaymentSummary = orderDto.PaymentSummary,
59        PaymentIntentId = cart.PaymentIntentId,
60        BuyerEmail = email
61    };
62
63    unit.Repository<Order>().Add(order);
64
65    if (await unit.Complete())
66    {
67        return order;
68    }
69
70    return BadRequest("Problem creating order");
71}

In this code lets take a look at the changes:

Line 1: We are invalidating the cache as this will ensure the updated product quantity is returned for the next request.

Lines 21-24: We check to ensure we have enough stock to fulfil the order and if not return a bad request. We prevent the order even if other products in stock are ordered to allow the user to update their cart.

Line 44: We use the product repository to update the affected product.

Refactoring the order and payment submission

Now that we are checking the stock in the order creation, we need to ensure this part happens first to avoid the scenario where the user makes a payment only to find out afterwards that the items in the order are not in stock.

Note: We are preventing the order from taking place here if the item is not in stock (lets call it the Sony Playstation 5 strategy), but an alternative approach (lets call it the Apple any product strategy) is to have backorder handling and create a system to manage orders for out-of-stock items and automatically fulfil them when restocked. This is more complex so we will go for the simpler option - the “PS5” strategy.

In the checkout.component.ts replace the confirmPayment() method with the following:

1async confirmPayment(stepper: MatStepper) {
2    this.loading = true;
3  
4    try {
5      if (!this.confirmationToken) {
6        throw new Error('No confirmation token available');
7      }
8  
9      // Create the order first to check stock
10      const order = await this.createOrderModel();
11      const orderResult = await firstValueFrom(this.orderService.createOrder(order));
12  
13      if (!orderResult) {
14        throw new Error('Order creation failed or out of stock');
15      }
16  
17      // Proceed with payment after order creation
18      const paymentResult = await this.stripeService.confirmPayment(this.confirmationToken);
19  
20      if (paymentResult.paymentIntent?.status === 'succeeded') {
21        await this.handleOrderSuccess();
22      } else if (paymentResult.error) {
23        throw new Error(paymentResult.error.message);
24      } else {
25        throw new Error('Payment confirmation failed');
26      }
27    } catch (error: any) {
28      console.log(error.error);
29      this.handleError(error.error || error.message || 'Something went wrong', stepper);
30    } finally {
31      this.loading = false;
32    }
33  }
34  
35  private async handleOrderSuccess() {
36    // Complete the order after successful payment
37    this.orderService.orderComplete = true;
38    this.cartService.deleteCart();
39    this.cartService.selectedDelivery.set(null);
40    this.router.navigateByUrl('/checkout/success');
41  }
42  
43  private handleError(message: string, stepper: MatStepper) {
44    this.snackbar.error(message);
45    stepper.previous();
46  }

Now try and place an order as a normal user that cannot be fulfilled due to not enough stock. This should result in a 400 bad request.

Image

It’s not a perfect user experience as we have only bumped the user back to the payment but it will suffice for this example. There are a number of ways this could be improved if you are up for the challenge! Examples of this could be to:

  1. Bump the user back to the cart page and remove the item or reduce the quantity not in stock.
  2. Use SignalR and use realtime notifications to update the cart for online users with the current stock levels.

Handling payment failures after order is created

Another thing to handle is the fact that the order could be created but the payment fails now that we have swapped the payment and order creation around.

In the OrderAggregate OrderStatus.cs we already have a status for this:

1namespace Core.Entities.OrderAggregate;
2
3public enum OrderStatus
4{
5    Pending,
6    PaymentReceived,
7    PaymentFailed,
8    PaymentMismatch,
9    Refunded
10}
11

We can use this for this kind of circumstance. In the event that an order is created but the payment fails then we need to presume the user will give up and never come back so that means updating the quantities of stock removed when the order was placed, and setting the status of the order to PaymentFailed. We need to rely on the Webhook from Stripe for this function.

If a payment fails the payment intent status will be ‘requires_payment_method’ as per the docs here.

Image

Update the StripeWebHook() method in the PaymentsController.cs in the API project to the following:

1 [HttpPost("webhook")]
2    public async Task<IActionResult> StripeWebhook()
3    {
4        var json = await new StreamReader(Request.Body).ReadToEndAsync();
5
6        try
7        {
8            var stripeEvent = ConstructStripeEvent(json);
9
10            if (stripeEvent.Data.Object is not PaymentIntent intent)
11            {
12                return BadRequest("Invalid event data");
13            }
14
15            if (intent.Status == "succeeded") await HandlePaymentIntentSucceeded(intent);
16            else await HandlePaymentIntentFailed(intent);
17
18            return Ok();
19        }
20        catch (StripeException ex)
21        {
22            logger.LogError(ex, "Stripe webhook error");
23            return StatusCode(StatusCodes.Status500InternalServerError, "Webhook error");
24        }
25        catch (Exception ex)
26        {
27            logger.LogError(ex, "An unexpected error occurred");
28            return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred");
29        }
30    }
31
32    private async Task HandlePaymentIntentSucceeded(PaymentIntent intent)
33    {
34        var spec = new OrderSpecification(intent.Id, true);
35
36        var order = await unit.Repository<Order>().GetEntityWithSpec(spec)
37            ?? throw new Exception("Order not found");
38
39        var orderTotalInCents = (long)Math.Round(order.GetTotal() * 100,
40            MidpointRounding.AwayFromZero);
41
42        if (orderTotalInCents != intent.Amount)
43        {
44            order.Status = OrderStatus.PaymentMismatch;
45        }
46        else
47        {
48            order.Status = OrderStatus.PaymentReceived;
49        }
50
51        await unit.Complete();
52
53        var connectionId = NotificationHub.GetConnectionIdByEmail(order.BuyerEmail);
54
55        if (!string.IsNullOrEmpty(connectionId))
56        {
57            await hubContext.Clients.Client(connectionId)
58                .SendAsync("OrderCompleteNotification", order.ToDto());
59        }
60    }
61
62    private async Task HandlePaymentIntentFailed(PaymentIntent intent)
63    {
64        // create spec for order with order items based on intent id
65        var spec = new OrderSpecification(intent.Id, true);
66
67        var order = await unit.Repository<Order>().GetEntityWithSpec(spec)
68            ?? throw new Exception("Order not found");
69
70        // update quantities for the products in stock based on the failed order
71        foreach (var item in order.OrderItems)
72        {
73            var productItem = await unit.Repository<Core.Entities.Product>()
74                .GetByIdAsync(item.ItemOrdered.ProductId)
75                    ?? throw new Exception("Problem updating order stock");
76
77            productItem.QuantityInStock += item.Quantity;
78        }
79
80        order.Status = OrderStatus.PaymentFailed;
81
82        await unit.Complete();
83    }

In this code we check in the StripeWebhook method to see if the intent succeeded or not and either call the HandlePaymentIntentSucceeded method if the payment was successful or the HandlePaymentIntentFailed if it did not. The client will be notified of failure directly by Stripe so we do not need to use SignalR to notify them, but we do need to clean up the order, give it the PaymentFailed status and update the quantities in stock for any component that was ordered.

Since it is now possible we already have an order when a user completes checkout (if an order was created but payment failed) we now need to account for this in the OrdersController.cs in the API.

Update the CreateOrder() method to the following:

1[InvalidateCache("api/products|")]
2    [HttpPost]
3    public async Task<ActionResult<Order>> CreateOrder(CreateOrderDto orderDto)
4    {
5        var email = User.GetEmail();
6
7        var cart = await cartService.GetCartAsync(orderDto.CartId);
8        if (cart == null) return BadRequest("Cart not found");
9
10        if (cart.PaymentIntentId == null) return BadRequest("No payment intent for this order");
11
12        var items = new List<OrderItem>();
13
14        foreach (var item in cart.Items)
15        {
16            var productItem = await unit.Repository<Product>().GetByIdAsync(item.ProductId);
17            if (productItem == null) return BadRequest($"Product with ID {item.ProductId} not found");
18
19            if (productItem.QuantityInStock < item.Quantity)
20            {
21                return BadRequest($"Not enough stock for product {item.ProductName}. Available stock: {productItem.QuantityInStock}");
22            }
23
24            // Reduce stock by the ordered quantity
25            productItem.QuantityInStock -= item.Quantity;
26
27            var itemOrdered = new ProductItemOrdered
28            {
29                ProductId = item.ProductId,
30                ProductName = item.ProductName,
31                PictureUrl = item.PictureUrl
32            };
33
34            var orderItem = new OrderItem
35            {
36                ItemOrdered = itemOrdered,
37                Price = productItem.Price,
38                Quantity = item.Quantity
39            };
40            items.Add(orderItem);
41
42            // Update product stock
43            unit.Repository<Product>().Update(productItem);
44        }
45
46        var deliveryMethod = await unit.Repository<DeliveryMethod>().GetByIdAsync(orderDto.DeliveryMethodId);
47        if (deliveryMethod == null) return BadRequest("No delivery method selected");
48
49        // Create the new order object
50        var order = new Order
51        {
52            OrderItems = items,
53            DeliveryMethod = deliveryMethod,
54            ShippingAddress = orderDto.ShippingAddress,
55            Subtotal = items.Sum(x => x.Price * x.Quantity),
56            Discount = orderDto.Discount,
57            PaymentSummary = orderDto.PaymentSummary,
58            PaymentIntentId = cart.PaymentIntentId,
59            BuyerEmail = email
60        };
61
62        // Check if there is an existing order for the same payment intent
63        var spec = new OrderSpecification(cart.PaymentIntentId, true);
64        var existingOrder = await unit.Repository<Order>().GetEntityWithSpec(spec);
65
66        if (existingOrder != null)
67        {
68            // Merge fields to preserve the existing order while updating necessary details
69            existingOrder.OrderItems = order.OrderItems;
70            existingOrder.DeliveryMethod = order.DeliveryMethod;
71            existingOrder.ShippingAddress = order.ShippingAddress;
72            existingOrder.Subtotal = order.Subtotal;
73            existingOrder.Discount = order.Discount;
74            existingOrder.PaymentSummary = order.PaymentSummary;
75            existingOrder.BuyerEmail = order.BuyerEmail;
76
77            // Update existing order
78            unit.Repository<Order>().Update(existingOrder);
79            order = existingOrder; // Set the return value to the updated order
80        }
81        else
82        {
83            // Add new order
84            unit.Repository<Order>().Add(order);
85        }
86
87        // Save changes to the database (including stock updates)
88        if (await unit.Complete())
89        {
90            return order;
91        }
92
93        return BadRequest("Problem creating order");
94    }

Here we check to see if we do have an existing order and merge this in with the (potentially updated) order being sent up to the API.

We now have everything in place to accommodate the order creation and payment failures. Lets test this with an order that should be accepted and a failed payment.

1. Browse to the app as a normal user and add a product that is in stock to the cart.

Image

2. Checkout and make your way to the payment card step. Enter a test card number that will result in failure from here.

Image

3. On the next step click the Pay button and the card should be declined pushing you back to the payment card step with the notification:

Image

At this point the order should have been created and based on the webhook the order should have a status of PaymentFailed. Check the orders to confirm this:

Image

Also make sure the Catalog reflects the correct stock number for this failed order. In my case before the order was placed the Core Blue Hat was at 31, after the order failed it is still 31.

Retry the payment with a good card number and ensure the order is created, the order status is correct and the stock is reduced. This should work:

Image

So we have made it and implemented a basic product management system in the app! Congratulations.

You can now commit your changes to GitHub for this new functionality by using the following commands:

1git add .
2git commit -m "Inventory tutorial complete"
3git push origin inventory

If you wish this functionality to be deployed to your production app and you have the CI/CD workflow in place to automatically deploy this code to Azure (that was covered in the main course) then you can then Compare a pull request, then merge in your changes from the new branch.

Image

That's the inventory tutorial complete.