diff --git a/README.md b/README.md index 77bb8b29..64463452 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ -# Xperience by Kentico - KX 13 E-Commerce integration +# Xperience by Kentico - Kentico Xperience 13 E-commerce -[![Kentico Labs](https://img.shields.io/badge/Kentico_Labs-grey?labelColor=orange&logo=data:image/svg+xml;base64,PHN2ZyBjbGFzcz0ic3ZnLWljb24iIHN0eWxlPSJ3aWR0aDogMWVtOyBoZWlnaHQ6IDFlbTt2ZXJ0aWNhbC1hbGlnbjogbWlkZGxlO2ZpbGw6IGN1cnJlbnRDb2xvcjtvdmVyZmxvdzogaGlkZGVuOyIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik05NTYuMjg4IDgwNC40OEw2NDAgMjc3LjQ0VjY0aDMyYzE3LjYgMCAzMi0xNC40IDMyLTMycy0xNC40LTMyLTMyLTMyaC0zMjBjLTE3LjYgMC0zMiAxNC40LTMyIDMyczE0LjQgMzIgMzIgMzJIMzg0djIxMy40NEw2Ny43MTIgODA0LjQ4Qy00LjczNiA5MjUuMTg0IDUxLjIgMTAyNCAxOTIgMTAyNGg2NDBjMTQwLjggMCAxOTYuNzM2LTk4Ljc1MiAxMjQuMjg4LTIxOS41MnpNMjQxLjAyNCA2NDBMNDQ4IDI5NS4wNFY2NGgxMjh2MjMxLjA0TDc4Mi45NzYgNjQwSDI0MS4wMjR6IiAgLz48L3N2Zz4=)](https://github.com/Kentico/.github/blob/main/SUPPORT.md#labs-limited-support) +[![7-day bug-fix policy](https://img.shields.io/badge/-7--days_bug--fixing_policy-grey?labelColor=orange&logo=data:image/svg+xml;base64,PHN2ZyBjbGFzcz0ic3ZnLWljb24iIHN0eWxlPSJ3aWR0aDogMWVtOyBoZWlnaHQ6IDFlbTt2ZXJ0aWNhbC1hbGlnbjogbWlkZGxlO2ZpbGw6IGN1cnJlbnRDb2xvcjtvdmVyZmxvdzogaGlkZGVuOyIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik04ODguNDkgMjIyLjY4NnYtMzEuNTRsLTY1LjY3Mi0wLjk1NWgtMC4yMDVhNDY1LjcxNSA0NjUuNzE1IDAgMCAxLTE0NC4zMTUtMzEuMzM0Yy03Ny4wMDUtMzEuMTk4LTEyNi4yOTQtNjYuNzY1LTEyNi43MDMtNjcuMTA3bC0zOS44LTI4LjY3Mi0zOS4xODUgMjguNDY4Yy0yLjA0OCAxLjUwMS00OS45MDMgMzYuMDQ0LTEyNi45MDggNjcuMzFhNDQ3LjQyIDQ0Ny40MiAwIDAgMS0xNDQuNTIgMzEuMzM1bC02NS44NzcgMC45NTZ2Mzc4Ljg4YzAgODcuMDQgNDkuODM0IDE4NC42NjEgMTM3LjAxIDI2Ny44MSAzNy41NDcgMzUuODQgNzkuMjU4IDY2LjM1NSAxMjAuODMzIDg4LjIgNDMuMjggMjIuNzMzIDg0LjI0IDM0LjYxMiAxMTguODUyIDM0LjYxMiAzNC40MDYgMCA3NS43NzYtMTIuMTUyIDExOS42MDMtMzUuMTU4YTU0Ny45NzcgNTQ3Ljk3NyAwIDAgMCAxMjAuMDEzLTg3LjY1NCA1MTUuMjA5IDUxNS4yMDkgMCAwIDAgOTYuMTg4LTEyMi44OGMyNy4xMDItNDkuNTYyIDQwLjgyMy05OC4zMDQgNDAuODIzLTE0NC45OTlsLTAuMTM2LTM0Ny4yMDR6TTUxMC4wOSAxNDMuNDI4bDEuNzA2LTEuMzY1IDEuNzc1IDEuMzY1YzUuODAzIDQuMTY1IDU5LjUyOSA0MS44NDggMTQwLjM1NiA3NC43NTIgNzkuMTkgMzIuMDg2IDE1My42IDM1LjYzNSAxNjcuNjYzIDM2LjA0NWwyLjU5NCAwLjA2OCAwLjIwNSAzMTUuNzM0YzAuMTM3IDY5LjQ5NS00Mi41OTggMTUwLjE4Ni0xMTcuMDc3IDIyMS40NTdDNjQxLjU3IDg1NC4yODkgNTYzLjEzIDg5Ni40NzggNTEyIDg5Ni40NzhjLTIzLjY4OSAwLTU1LjU3LTkuODk5LTg5LjcwMi0yNy43ODVhNDc4LjgyMiA0NzguODIyIDAgMCAxLTEwNS42MDktNzcuMjc4QzI0Mi4yMSA3MjAuMjEzIDE5OS40NzUgNjM5LjUyMiAxOTkuNDc1IDU2OS44OVYyNTQuMjI1bDIuNzMtMC4xMzZjMy4yNzggMCA4Mi42MDQtMS41MDIgMTY3LjY2NC0zNS45NzdhNzM5Ljk0MiA3MzkuOTQyIDAgMCAwIDE0MC4yMi03NC42MTV2LTAuMDY5eiIgIC8+PHBhdGggZD0iTTcxMy4zMTggMzY4LjY0YTMyLjIyMiAzMi4yMjIgMCAwIDAtNDUuMzI5IDBMNDQ5LjE5NSA1ODcuNDM1bC05My4xODQtOTMuMTE2YTMyLjIyMiAzMi4yMjIgMCAwIDAtNDUuMzMgMCAzMi4yMjIgMzIuMjIyIDAgMCAwIDAgNDUuMjZsMTE1Ljg1IDExNS44NWEzMi4yOSAzMi4yOSAwIDAgMCA0NS4zMjggMEw3MTMuMzIgNDEzLjlhMzIuMjIyIDMyLjIyMiAwIDAgMCAwLTQ1LjMzeiIgIC8+PC9zdmc+)](https://github.com/Kentico/.github/blob/main/SUPPORT.md#full-support) [![CI: Build and Test](https://github.com/Kentico/xperience-by-kentico-k13ecommerce/actions/workflows/ci.yml/badge.svg)](https://github.com/Kentico/xperience-by-kentico-k13ecommerce/actions/workflows/ci.yml) -**This integration is currently a Proof of Concept (PoC). For further details, please refer to the [Support](#support) section and the [Kentico Labs](https://github.com/Kentico/.github/blob/main/SUPPORT.md#labs-limited-support) tag associated with this feature.** - | Name | Package | | ------------- |:-------------:| | Kentico.Xperience.K13Ecommerce | [![NuGet Package](https://img.shields.io/nuget/v/Kentico.Xperience.K13Ecommerce.svg)](https://www.nuget.org/packages/Kentico.Xperience.K13Ecommerce) | @@ -43,8 +41,10 @@ is located in [Dancing Goat XbyK example project](./examples/DancingGoat-K13Ecom The integration provides an API with services for implementing the following scenarios: - Listing products based on parameters, product categories, prices and inventory - Actions with shopping cart, changing currency and order creation - - Listing of orders (currently suitable for implementing listing orders in administration) - - **Order updates and listing for specific customers are under development** + - Listing of orders in the administration + - Listing of orders on the live site for the current customer + - Updates of existing orders + - Retrieving and listing order statuses - Listing site cultures and currencies - Check [this part of User Guide](./docs/Usage-Guide.md#kx-13-e-commerce-integration-in-xperience-by-kentico) for more specific description @@ -169,6 +169,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment environment) "ClientId": "3ef7fe1b-696c-4afa-8b56-d3176b7bea95", "ClientSecret": "********************", "ProductSyncEnabled": true, + "StandaloneProductSync": true, "ProductSyncInterval": 10 } } @@ -207,8 +208,8 @@ Distributed under the MIT License. See [`LICENSE.md`](./LICENSE.md) for more inf ## Support -This contribution has __Kentico Labs limited support__. +This contribution has __Full Support__. -See [`SUPPORT.md`](https://github.com/Kentico/.github/blob/main/SUPPORT.md#labs-limited-support) for more information. +See [`SUPPORT.md`](https://github.com/Kentico/.github/blob/main/SUPPORT.md) for more information. For any security issues see [`SECURITY.md`](https://github.com/Kentico/.github/blob/main/SECURITY.md). diff --git a/docs/Usage-Guide.md b/docs/Usage-Guide.md index 53de2128..bccd9948 100644 --- a/docs/Usage-Guide.md +++ b/docs/Usage-Guide.md @@ -77,6 +77,10 @@ via API. ### Orders - Endpoint `api/store/order/list` for retrieving list of orders for current customer based on request (supports paging) - Endpoint `api/store/order/admin/list` for retrieving list of orders (for all customers) based on request (supports paging) to display in XbyK administration (supports paging) +- Endpoint `api/store/order/detail/{orderID}` for retrieving order details for the current customer. If the order belongs to another customer, no order is retrieved +- Endpoint `api/store/order/admin/detail/{orderID}` for retrieving order details (without verifying if the order belongs to the current customer) +- Endpoint `api/store/order/statuses/list` for retrieving all order statuses +- Endpoint `api/store/order/update` for updating orders (update order status, set order payment, etc.). Primarily intended to be used via `IOrderService` available in the [integration API](https://github.com/Kentico/xperience-by-kentico-k13ecommerce/pull/16#kx-13-e-commerce-integration-in-xperience-by-kentico) ### Customers - Endpoint `api/store/customer/addresses` for retrieving current customer's addresses @@ -90,7 +94,7 @@ via API. When [member](https://docs.kentico.com/x/BIsuCw) is created on XbyK (for example when a new customer registers), this member needs to be synchronized to KX 13 as a user. It is subsequently used for API authorization (member/user identity is generated in JWT). Before you start using the Store API, you need to synchronize all website members between the client (XbyK) and your KX 13 application. -Complete synchronization is not part of this PoC solution. +Complete synchronization is currently not a part of this solution. - Endpoint `api/store/synchronization/user-synchronization` can be used to create a new user in KX 13 - The client application (XbyK) should use this to ensure that all new members are synchronized to KX 13. This is necessary when client's @@ -218,8 +222,12 @@ and to browser cookie (uses `IShoppingCartClientStorage`) - Service is used e.g. in [CheckoutService in Dancing Goat example](../examples/DancingGoat-K13Ecommerce/Services/CheckoutService.cs) where customer's addresses are retrieved in cart's second step. - `IOrderService` - - List of orders - currently suitable for implementing listing orders in administration - - **Order updates and listing for specific customers are under development** + - List orders from all customers (for implementing order listings in the administration) + - List orders for the current customer (based on the request context) + - Retrieve order details for the current customer (only for orders that belong to the customer) + - Retrieve order details for administrators (without verifying if the order belongs to the current customer) + - List all order statuses + - Update existing orders (order status, payment, etc.) - `ISiteStoreService` - Use for retrieving site's [list of enabled cultures](https://github.com/Kentico/xperience-by-kentico-ecommerce/blob/main/src/Kentico.Xperience.K13Ecommerce/SiteStore/ISiteStoreService.cs#L13), e.g. for implementation of language selector - Use for retrieving site's [list of enabled currencies](https://github.com/Kentico/xperience-by-kentico-ecommerce/blob/main/src/Kentico.Xperience.K13Ecommerce/SiteStore/ISiteStoreService.cs#L18), e.g. for implementation of currency selector @@ -232,7 +240,7 @@ and to browser cookie (uses `IShoppingCartClientStorage`) Library also implements product synchronization to Content hub. These are 3 entities synchronized to reusable content items: - Products - Content type `K13Store.ProductSKU` - - All products associated with product pages are synced. **Standalone SKUs** aren't currently supported. + - All products associated with product pages are synced. **Standalone SKUs** synchronization can be set via `StandaloneProductSync` setting. - Product variants - Content type `K13Store.ProductVariant` - All products variant for parent products - Product images - Content type `K13Store.ProductImage` @@ -241,7 +249,7 @@ Library also implements product synchronization to Content hub. These are 3 enti The synchronization runs in a background thread worker periodically and can be disabled (`ProductSyncEnabled` setting). Interval can be set in minutes (`ProductSyncInterval` setting). Synchronized data is updated when source value changes, so data cannot be edited in XbyK safely, but new custom or reusable fields can be added and edited -safely. +safely. You can decide, whether include [standalone SKUs](https://docs.kentico.com/x/3gqRBg) or not (`StandaloneProductSync` setting). You can select content item folders where content items are synchronized. Content item folders can be selected independently for each content type in XbyK administration UI. Go to **Configuration** -> **K13Ecommerce** -> **K13Ecommerce settings**. Content items are not moved if root folder is selected. @@ -302,6 +310,7 @@ dotnet add package Kentico.Xperience.Store.Rcl "ClientId": "3ef7fe1b-696c-4afa-8b56-d3176b7bea95", "ClientSecret": "********************", "ProductSyncEnabled": true, + "StandaloneProductSync": true, "ProductSyncInterval": 10 } } @@ -314,6 +323,7 @@ dotnet add package Kentico.Xperience.Store.Rcl | ClientId | Fill same value which is defined on KX 13 side | | ClientSecret | Fill same value which is defined on KX 13 side | | ProductSyncEnabled | If true, product synchronization is enabled | +| StandaloneProductSync | If this setting along with `ProductSyncEnabled` is true, [standalone SKUs](https://docs.kentico.com/x/3gqRBg) are synchronized as well (if `ProductSyncEnabled` is false, no products are synchronized). | | ProductSyncInterval | Interval in minutes specifies how often synchronization is running | @@ -420,7 +430,7 @@ Here are links for some specific parts of shopping cart: - [Discount / Coupon codes](https://github.com/Kentico/xperience-by-kentico-ecommerce/blob/main/examples/DancingGoat-K13Ecommerce/Controllers/KStore/CheckoutController.cs#L163) - [Delivery details + shipping](https://github.com/Kentico/xperience-by-kentico-ecommerce/blob/main/examples/DancingGoat-K13Ecommerce/Controllers/KStore/CheckoutController.cs#L194) - [Payment](https://github.com/Kentico/xperience-by-kentico-ecommerce/blob/main/examples/DancingGoat-K13Ecommerce/Controllers/KStore/CheckoutController.cs#L330) -- Payment gateway - Is not part of this PoC solution, you need to implement integration with specific payment gateway. **API for updating orders (and their statuses) is under development**. +- Payment gateway - Is not currently part of the solution. You need to implement integration with a specific payment gateway. - [Order creation](https://github.com/Kentico/xperience-by-kentico-ecommerce/blob/main/examples/DancingGoat-K13Ecommerce/Controllers/KStore/CheckoutController.cs#L315) ### How to enable automatic product page synchronization? @@ -473,4 +483,9 @@ mapped e.g. in another folder or website channel: #### Known limitations Avoid creating rules that map the NodeAliasPath of different content items to a single product page tree path. Such rules cause the linked content item to be overwritten for particular pages. -If you change existing mapping rules, already created pages will not be moved accordingly. Instead, they will be created in the new location. \ No newline at end of file +If you change existing mapping rules, already created pages will not be moved accordingly. Instead, they will be created in the new location. + +### How to handle order payments? +1. Implement your own payment method. +2. Retrieve all order statuses using `IOrderService` if needed. +3. Use `UpdateOrder` method of `IOrderService` to update order status and to set `OrderIsPaid` flag according to the payment result. \ No newline at end of file diff --git a/examples/DancingGoat-K13Ecommerce/Controllers/TestController.cs b/examples/DancingGoat-K13Ecommerce/Controllers/TestController.cs index de833ceb..3e4ca008 100644 --- a/examples/DancingGoat-K13Ecommerce/Controllers/TestController.cs +++ b/examples/DancingGoat-K13Ecommerce/Controllers/TestController.cs @@ -29,5 +29,43 @@ public async Task TestSetCurrency(string currencyCode) public async Task TestOrders([FromServices] IOrderService orderService) => Json(await orderService.GetCurrentCustomerOrderList(new OrderListRequest { Page = 1, PageSize = 10, OrderBy = "OrderID DESC" })); + + public async Task TestOrderStatuses([FromServices] IOrderService orderService) + { + var statuses = await orderService.GetOrderStatuses(); + return Json(statuses); + } + + public async Task TestUpdateOrder([FromServices] IOrderService orderService) + { + var orders = + await orderService.GetAdminOrderList(new OrderListRequest { Page = 1, PageSize = 10, OrderBy = "OrderID DESC" }); + + var order = orders.Orders.First(o => o.OrderId == 25); + + order.OrderGrandTotal = 999; + order.OrderIsPaid = true; + var newStatus = (await orderService.GetOrderStatuses()).First(o => o.StatusName == "Completed"); + order.OrderStatus = newStatus; + + order.OrderBillingAddress.AddressLine1 = "123 Main St"; + order.OrderBillingAddress.AddressCity = "New York"; + + order.OrderShippingAddress.AddressZip = "10001"; + + order.OrderShippingOption = new KShippingOption { ShippingOptionId = 2 }; + order.OrderPaymentOption = new KPaymentOption { PaymentOptionId = 1 }; + + order.OrderPaymentResult = new KPaymentResult + { + PaymentIsCompleted = true, + PaymentMethodName = "Test", + PaymentStatusName = "Test status" + }; + + await orderService.UpdateOrder(order); + + return Ok(); + } } #endif diff --git a/examples/DancingGoat-K13Ecommerce/appsettings.json b/examples/DancingGoat-K13Ecommerce/appsettings.json index e97cc90b..8ffed5c8 100644 --- a/examples/DancingGoat-K13Ecommerce/appsettings.json +++ b/examples/DancingGoat-K13Ecommerce/appsettings.json @@ -19,6 +19,7 @@ "ClientId": "YourUniqueClientIdentifier", "ClientSecret": "********************", "ProductSyncEnabled": true, + "StandaloneProductSync": true, "ProductSyncInterval": 10 } } \ No newline at end of file diff --git a/examples/Kentico13_DancingGoatStore/ShopApi/CustomSKUConverter.cs b/examples/Kentico13_DancingGoatStore/ShopApi/CustomSKUConverter.cs index 33140a37..5dcf28ff 100644 --- a/examples/Kentico13_DancingGoatStore/ShopApi/CustomSKUConverter.cs +++ b/examples/Kentico13_DancingGoatStore/ShopApi/CustomSKUConverter.cs @@ -10,9 +10,9 @@ namespace DancingGoat.ShopApi; /// public class CustomSKUConverter : ProductSKUConverter { - public override CustomSKU Convert(SKUInfo skuInfo, string currencyCode, bool withVariants) + public override CustomSKU Convert(SKUInfo skuInfo, string currencyCode, bool withVariants, bool withLongDescription) { - var model = base.Convert(skuInfo, currencyCode, withVariants); + var model = base.Convert(skuInfo, currencyCode, withVariants, withLongDescription); return model; } diff --git a/src/Kentico.Xperience.K13Ecommerce/Config/KenticoStoreConfig.cs b/src/Kentico.Xperience.K13Ecommerce/Config/KenticoStoreConfig.cs index 6d99c2f9..12a822ab 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Config/KenticoStoreConfig.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Config/KenticoStoreConfig.cs @@ -20,6 +20,11 @@ public class KenticoStoreConfig /// public required string ClientSecret { get; set; } = string.Empty; + /// + /// Synchronize also standalone products without page representation. + /// + public required bool StandaloneProductSync { get; set; } = true; + /// /// When true, product synchronization is enabled. /// diff --git a/src/Kentico.Xperience.K13Ecommerce/Orders/IOrderService.cs b/src/Kentico.Xperience.K13Ecommerce/Orders/IOrderService.cs index fd397946..f87dcb53 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Orders/IOrderService.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Orders/IOrderService.cs @@ -21,4 +21,31 @@ public interface IOrderService /// Request parameters for order listing. /// Paged list of orders. Task GetAdminOrderList(OrderListRequest request); + + + /// + /// Get order by id for current customer. + /// + /// Order ID. + Task GetCurrentCustomerOrder(int orderId); + + + /// + /// Get order by ID to display in XbyK administration. + /// + /// Order ID. + Task GetAdminOrder(int orderId); + + + /// + /// Returns list of enabled order statuses. + /// + Task> GetOrderStatuses(); + + + /// + /// Updates order. + /// + /// Order Dto, send full data for order - retrieve order data first. + Task UpdateOrder(KOrder order); } diff --git a/src/Kentico.Xperience.K13Ecommerce/Orders/OrderListRequest.cs b/src/Kentico.Xperience.K13Ecommerce/Orders/OrderListRequest.cs index 5b17d59d..565e8ed7 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Orders/OrderListRequest.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Orders/OrderListRequest.cs @@ -19,4 +19,9 @@ public class OrderListRequest /// Order by. /// public required string OrderBy { get; set; } + + /// + /// Customer ID, leave null or zero for all customers. + /// + public int? CustomerId { get; set; } } diff --git a/src/Kentico.Xperience.K13Ecommerce/Orders/OrderService.cs b/src/Kentico.Xperience.K13Ecommerce/Orders/OrderService.cs index 4680615e..b8f28e2d 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Orders/OrderService.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Orders/OrderService.cs @@ -12,4 +12,20 @@ public async Task GetCurrentCustomerOrderList(OrderListReques /// public async Task GetAdminOrderList(OrderListRequest request) => await storeApiClient.AdminOrderListAsync(request.Page, request.PageSize, request.OrderBy); + + + /// + public async Task GetCurrentCustomerOrder(int orderId) => await storeApiClient.OrderDetailAsync(orderId); + + + /// + public async Task GetAdminOrder(int orderId) => await storeApiClient.AdminOrderDetailAsync(orderId); + + + /// + public async Task> GetOrderStatuses() => await storeApiClient.OrderStatusesListAsync(); + + + /// + public async Task UpdateOrder(KOrder order) => await storeApiClient.UpdateOrderAsync(order); } diff --git a/src/Kentico.Xperience.K13Ecommerce/Products/IProductService.cs b/src/Kentico.Xperience.K13Ecommerce/Products/IProductService.cs index 49d49537..479f2d7b 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Products/IProductService.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Products/IProductService.cs @@ -36,4 +36,12 @@ public interface IProductService /// SKUID for product or variant. /// Currency code. Task GetVariantInventoryPriceInfo(int skuId, string? currencyCode = null); + + + /// + /// Gets all standalone products. + /// + /// Stanadlone product params. + /// + Task> GetStandaloneProducts(ProductRequest request); } diff --git a/src/Kentico.Xperience.K13Ecommerce/Products/ProductPageRequest.cs b/src/Kentico.Xperience.K13Ecommerce/Products/ProductPageRequest.cs index a0d35390..dff2db93 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Products/ProductPageRequest.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Products/ProductPageRequest.cs @@ -3,7 +3,7 @@ /// /// Model for request to filter product pages /// -public class ProductPageRequest +public class ProductPageRequest : ProductRequest { /// /// Node alias path prefix @@ -11,36 +11,6 @@ public class ProductPageRequest public required string Path { get; set; } - /// - /// Document culture - /// - public string? Culture { get; set; } - - - /// - /// Product currency - /// - public string? Currency { get; set; } - - - /// - /// Order by SQL expression - /// - public string? OrderBy { get; set; } - - - /// - /// Limit how many products to return. - /// - public int? Limit { get; set; } - - - /// - /// If true variants are loaded too for products with variants (default false). - /// - public bool WithVariants { get; set; } - - /// /// If true, DocumentSKUDescription is filled too (default false). /// diff --git a/src/Kentico.Xperience.K13Ecommerce/Products/ProductRequest.cs b/src/Kentico.Xperience.K13Ecommerce/Products/ProductRequest.cs new file mode 100644 index 00000000..1758d47a --- /dev/null +++ b/src/Kentico.Xperience.K13Ecommerce/Products/ProductRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace Kentico.Xperience.K13Ecommerce.Products; + +/// +/// Model for request to filter standalone products +/// +public class ProductRequest +{ + /// + /// To determine if product has its page, culture needs to be provided + /// so documents in the specific culture will be checked. + /// + [RegularExpression("[a-zA-Z]{2}-[a-zA-Z]{2}")] + public string? Culture { get; set; } + + /// + /// Product currency. + /// + public string? Currency { get; set; } + + /// + /// Order by SQL expression. + /// + public string? OrderBy { get; set; } + + /// + /// Limit how many products to return. + /// + [DefaultValue(12)] + [Range(1, 1000)] + public int Limit { get; set; } + + /// + /// If true variants are loaded too for products with variants (default false). + /// + public bool WithVariants { get; set; } +} + diff --git a/src/Kentico.Xperience.K13Ecommerce/Products/ProductService.cs b/src/Kentico.Xperience.K13Ecommerce/Products/ProductService.cs index fc7ce94b..776b4d8e 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Products/ProductService.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Products/ProductService.cs @@ -6,8 +6,8 @@ internal class ProductService(IKenticoStoreApiClient storeApiClient) : IProductS { /// public async Task> GetProductPages(ProductPageRequest request) - => await storeApiClient.GetProductPagesAsync(request.Path, request.Culture, request.Currency, request.OrderBy, - request.Limit, request.WithVariants, request.WithLongDescription, request.NoLinks); + => await storeApiClient.GetProductPagesAsync(request.Path, request.WithLongDescription, request.NoLinks, request.Culture, + request.Currency, request.OrderBy, request.Limit, request.WithVariants); /// @@ -24,4 +24,8 @@ public async Task GetProductPrices(int skuId, string? cur /// public async Task GetVariantInventoryPriceInfo(int skuId, string? currencyCode = null) => await storeApiClient.GetInventoryPricesAsync(skuId, currencyCode); + + /// + public async Task> GetStandaloneProducts(ProductRequest request) => + await storeApiClient.GetStandaloneProductsAsync(request.Culture, request.Currency, request.OrderBy, request.Limit, request.WithVariants); } diff --git a/src/Kentico.Xperience.K13Ecommerce/StoreApi/StoreItemIdentifiers.cs b/src/Kentico.Xperience.K13Ecommerce/StoreApi/StoreItemIdentifiers.cs index 1263c1f3..246ddb3a 100644 --- a/src/Kentico.Xperience.K13Ecommerce/StoreApi/StoreItemIdentifiers.cs +++ b/src/Kentico.Xperience.K13Ecommerce/StoreApi/StoreItemIdentifiers.cs @@ -9,6 +9,20 @@ public partial class KProductNode : IItemIdentifier { /// public int ExternalId => Sku.Skuid; + + /// + /// Create product node from product SKU. + /// + /// + public KProductNode(KProductSKU sku) + { + Sku = sku; + DocumentSKUName = sku.SkuName; + DocumentSKUDescription = sku.SkuLongDescription; + DocumentSKUShortDescription = sku.SkuShortDescription; + ClassName = string.Empty; + NodeAliasPath = string.Empty; + } } /// diff --git a/src/Kentico.Xperience.K13Ecommerce/StoreApi/swagger.json b/src/Kentico.Xperience.K13Ecommerce/StoreApi/swagger.json index ba166abd..b24af5c4 100644 --- a/src/Kentico.Xperience.K13Ecommerce/StoreApi/swagger.json +++ b/src/Kentico.Xperience.K13Ecommerce/StoreApi/swagger.json @@ -243,6 +243,14 @@ "schema": { "type": "string" } + }, + { + "name": "CustomerId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } } ], "responses": { @@ -301,6 +309,14 @@ "schema": { "type": "string" } + }, + { + "name": "CustomerId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } } ], "responses": { @@ -327,6 +343,170 @@ } } }, + "/api/store/order/detail/{orderId}": { + "get": { + "tags": [ + "Order" + ], + "summary": "Returns order by ID only if order belongs to current customer.", + "operationId": "OrderDetail", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "Order ID.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KOrder" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/store/order/admin/detail/{orderId}": { + "get": { + "tags": [ + "Order" + ], + "summary": "Returns order by ID.", + "operationId": "AdminOrderDetail", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "Order ID.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KOrder" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/store/order/statuses/list": { + "get": { + "tags": [ + "Order" + ], + "summary": "Endpoint for listing order statuses.", + "operationId": "OrderStatusesList", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KOrderStatus" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/store/order/update": { + "put": { + "tags": [ + "Order" + ], + "summary": "Updates order. Updates all fields on order level and addresses on sub-level. Customer data and order items cannot be updated.", + "operationId": "UpdateOrder", + "requestBody": { + "description": "Order dto", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KOrder" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/KOrder" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/KOrder" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/store/products/test": { "get": { "tags": [ @@ -367,10 +547,26 @@ "type": "string" } }, + { + "name": "WithLongDescription", + "in": "query", + "description": "If true, DocumentSKUDescription is filled too (default false).", + "schema": { + "type": "boolean" + } + }, + { + "name": "NoLinks", + "in": "query", + "description": "If true, only not-linked product pages are returned (default false).", + "schema": { + "type": "boolean" + } + }, { "name": "Culture", "in": "query", - "description": "Document culture.", + "description": "To determine if product has its page, culture needs to be provided\r\nso documents in the specific culture will be checked.", "schema": { "pattern": "[a-zA-Z]{2}-[a-zA-Z]{2}", "type": "string" @@ -411,22 +607,6 @@ "schema": { "type": "boolean" } - }, - { - "name": "WithLongDescription", - "in": "query", - "description": "If true, DocumentSKUDescription is filled too (default false).", - "schema": { - "type": "boolean" - } - }, - { - "name": "NoLinks", - "in": "query", - "description": "If true, only not-linked product pages are returned (default false).", - "schema": { - "type": "boolean" - } } ], "responses": { @@ -659,6 +839,87 @@ } } }, + "/api/store/products/standalone-products": { + "get": { + "tags": [ + "Products" + ], + "summary": "Returns all standalone products.", + "operationId": "GetStandaloneProducts", + "parameters": [ + { + "name": "Culture", + "in": "query", + "description": "To determine if product has its page, culture needs to be provided\r\nso documents in the specific culture will be checked.", + "schema": { + "pattern": "[a-zA-Z]{2}-[a-zA-Z]{2}", + "type": "string" + } + }, + { + "name": "Currency", + "in": "query", + "description": "Product currency.", + "schema": { + "type": "string" + } + }, + { + "name": "OrderBy", + "in": "query", + "description": "Order by SQL expression.", + "schema": { + "type": "string" + } + }, + { + "name": "Limit", + "in": "query", + "description": "Limit how many products to return.", + "schema": { + "maximum": 1000, + "minimum": 1, + "type": "integer", + "format": "int32", + "default": 12 + } + }, + { + "name": "WithVariants", + "in": "query", + "description": "If true variants are loaded too for products with variants (default false).", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KProductSKU" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/store/cart/content": { "get": { "tags": [ @@ -2279,6 +2540,10 @@ "KCurrency": { "type": "object", "properties": { + "currencyId": { + "type": "integer", + "format": "int32" + }, "currencyCode": { "type": "string", "nullable": true @@ -2289,7 +2554,7 @@ } }, "additionalProperties": false, - "description": "Represents currency from Store configuration." + "description": "Represents currency from Store configuration. CMS.Ecommerce.CurrencyInfo" }, "KCustomer": { "required": [ @@ -2399,6 +2664,12 @@ "description": "Dto for CMS.Ecommerce.ManufacturerInfo." }, "KOrder": { + "required": [ + "orderGrandTotal", + "orderId", + "orderTotalPrice", + "orderTotalTax" + ], "type": "object", "properties": { "orderId": { @@ -2417,9 +2688,8 @@ "type": "string", "nullable": true }, - "orderCurrencyCode": { - "type": "string", - "nullable": true + "orderCurrency": { + "$ref": "#/components/schemas/KCurrency" }, "orderShippingOption": { "$ref": "#/components/schemas/KShippingOption" @@ -2518,6 +2788,9 @@ "$ref": "#/components/schemas/KOrderItem" }, "nullable": true + }, + "orderPaymentResult": { + "$ref": "#/components/schemas/KPaymentResult" } }, "additionalProperties": false, @@ -2631,6 +2904,57 @@ "additionalProperties": false, "description": "Dto for CMS.Ecommerce.PaymentOptionInfo." }, + "KPaymentResult": { + "type": "object", + "properties": { + "paymentDate": { + "type": "string", + "nullable": true + }, + "paymentIsCompleted": { + "type": "boolean" + }, + "paymentIsFailed": { + "type": "boolean" + }, + "paymentIsAuthorized": { + "type": "boolean" + }, + "paymentDescription": { + "type": "string", + "nullable": true + }, + "paymentTransactionId": { + "type": "string", + "nullable": true + }, + "paymentAuthorizationID": { + "type": "string", + "nullable": true + }, + "paymentMethodName": { + "type": "string", + "nullable": true + }, + "paymentMethodID": { + "type": "integer", + "format": "int32" + }, + "paymentStatusValue": { + "type": "string", + "nullable": true + }, + "paymentStatusName": { + "type": "string", + "nullable": true + }, + "paymentApprovalUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "KProductCatalogPrices": { "type": "object", "properties": { @@ -2751,6 +3075,10 @@ "type": "string", "nullable": true }, + "skuLongDescription": { + "type": "string", + "nullable": true + }, "skuNumber": { "type": "string", "nullable": true @@ -3311,7 +3639,7 @@ "nullable": true } }, - "additionalProperties": { } + "additionalProperties": {} }, "ProductInventoryPriceInfo": { "type": "object", @@ -3418,7 +3746,7 @@ }, "security": [ { - "Bearer": [ ] + "Bearer": [] } ] } \ No newline at end of file diff --git a/src/Kentico.Xperience.K13Ecommerce/Synchronization/Products/ProductSynchronizationItem.cs b/src/Kentico.Xperience.K13Ecommerce/Synchronization/Products/ProductSynchronizationItem.cs index 526ba7b3..1c8de2f5 100644 --- a/src/Kentico.Xperience.K13Ecommerce/Synchronization/Products/ProductSynchronizationItem.cs +++ b/src/Kentico.Xperience.K13Ecommerce/Synchronization/Products/ProductSynchronizationItem.cs @@ -90,6 +90,7 @@ public bool GetModifiedProperties(ProductSKU contentItem, out Dictionary config) : SynchronizationServiceCommon(httpClientFactory), IProductSynchronizationService { public async Task SynchronizeProducts() { @@ -33,7 +36,7 @@ public async Task SynchronizeProducts() string language = defaultCultureCode[..2]; var kenticoStoreProducts = - await productService.GetProductPages(new ProductPageRequest + (await productService.GetProductPages(new ProductPageRequest { Path = "/", Culture = defaultCultureCode, @@ -41,7 +44,20 @@ await productService.GetProductPages(new ProductPageRequest WithVariants = true, WithLongDescription = true, NoLinks = true - }); + })).ToList(); + + if (config.CurrentValue.StandaloneProductSync) + { + var kenticoStandaloneProducts = (await productService.GetStandaloneProducts(new ProductRequest + { + Culture = defaultCultureCode, + Limit = 1000, + WithVariants = true + })).Select(p => new KProductNode(p)); + + kenticoStoreProducts.AddRange(kenticoStandaloneProducts); + } + var contentItemProducts = await contentItemService.GetContentItems(ProductSKU.CONTENT_TYPE_NAME, linkedItemsLevel: 2); diff --git a/src/Kentico.Xperience.StoreApi/Currencies/KCurrency.cs b/src/Kentico.Xperience.StoreApi/Currencies/KCurrency.cs index f261bb27..d8b0f213 100644 --- a/src/Kentico.Xperience.StoreApi/Currencies/KCurrency.cs +++ b/src/Kentico.Xperience.StoreApi/Currencies/KCurrency.cs @@ -1,10 +1,11 @@ namespace Kentico.Xperience.StoreApi.Currencies; /// -/// Represents currency from Store configuration. +/// Represents currency from Store configuration. /// public class KCurrency { + public int CurrencyId { get; set; } public string CurrencyCode { get; set; } public string CurrencyFormatString { get; set; } } diff --git a/src/Kentico.Xperience.StoreApi/Mapping/StoreApiMappingProfile.cs b/src/Kentico.Xperience.StoreApi/Mapping/StoreApiMappingProfile.cs index bb479123..04d3ffbd 100644 --- a/src/Kentico.Xperience.StoreApi/Mapping/StoreApiMappingProfile.cs +++ b/src/Kentico.Xperience.StoreApi/Mapping/StoreApiMappingProfile.cs @@ -56,21 +56,34 @@ public StoreApiMappingProfile() CreateMap() .AfterMap((source, dest, ctx) => { - dest.OrderCurrencyCode = CurrencyInfoProvider.ProviderObject.Get(source.OrderCurrencyID)?.CurrencyCode; + dest.OrderCurrency = + ctx.Mapper.Map(CurrencyInfoProvider.ProviderObject.Get(source.OrderCurrencyID)); dest.OrderCustomer = ctx.Mapper.Map(CustomerInfoProvider.ProviderObject.Get(source.OrderCustomerID)); dest.OrderShippingOption = ctx.Mapper.Map(ShippingOptionInfo.Provider.Get(source.OrderShippingOptionID)); - dest.OrderPaymentOption = ctx.Mapper.Map(PaymentOptionInfo.Provider.Get(source.OrderPaymentOptionID)); - dest.OrderItems = ctx.Mapper.Map>(OrderItemInfoProvider.GetOrderItems(source.OrderID)); - dest.OrderStatus = ctx.Mapper.Map(OrderStatusInfoProvider.ProviderObject.Get(source.OrderStatusID)); - }); + dest.OrderPaymentOption = + ctx.Mapper.Map(PaymentOptionInfo.Provider.Get(source.OrderPaymentOptionID)); + dest.OrderItems = + ctx.Mapper.Map>(OrderItemInfoProvider.GetOrderItems(source.OrderID)); + dest.OrderStatus = + ctx.Mapper.Map(OrderStatusInfoProvider.ProviderObject.Get(source.OrderStatusID)); + }) + .ReverseMap() + .ForPath(s => s.OrderCurrencyID, m => m.MapFrom(d => d.OrderCurrency.CurrencyId)) + .ForPath(s => s.OrderCustomerID, m => m.MapFrom(d => d.OrderCustomer.CustomerId)) + .ForPath(s => s.OrderShippingOptionID, m => m.MapFrom(d => d.OrderShippingOption.ShippingOptionId)) + .ForPath(s => s.OrderPaymentOptionID, m => m.MapFrom(d => d.OrderPaymentOption.PaymentOptionId)) + .ForPath(s => s.OrderStatusID, m => m.MapFrom(d => d.OrderStatus.StatusId)) + .ForPath(s => s.OrderBillingAddress, m => m.MapFrom(d => d.OrderBillingAddress)) + .ForPath(s => s.OrderShippingAddress, m => m.MapFrom(d => d.OrderShippingAddress)); CreateMap(); - CreateMap(); + CreateMap().ReverseMap(); CreateMap(); CreateMap>().ConvertUsing(); CreateMap(); + CreateMap().ReverseMap(); CreateMap() .AfterMap((source, dest, ctx) => diff --git a/src/Kentico.Xperience.StoreApi/Orders/KOrder.cs b/src/Kentico.Xperience.StoreApi/Orders/KOrder.cs index 4d10a332..901daf44 100644 --- a/src/Kentico.Xperience.StoreApi/Orders/KOrder.cs +++ b/src/Kentico.Xperience.StoreApi/Orders/KOrder.cs @@ -1,22 +1,25 @@ -using Kentico.Xperience.StoreApi.Customers; +using System.ComponentModel.DataAnnotations; + +using Kentico.Xperience.StoreApi.Currencies; +using Kentico.Xperience.StoreApi.Customers; using Kentico.Xperience.StoreApi.ShoppingCart; namespace Kentico.Xperience.StoreApi.Orders; /// -/// Dto for . +/// Dto for . /// public class KOrder { - public int OrderId { get; set; } + [Required] public int OrderId { get; set; } - public decimal OrderTotalTax { get; set; } + [Required] public decimal OrderTotalTax { get; set; } public string OrderTaxSummary { get; set; } public string OrderInvoiceNumber { get; set; } - public string OrderCurrencyCode { get; set; } + public KCurrency OrderCurrency { get; set; } public KShippingOption OrderShippingOption { get; set; } @@ -28,11 +31,11 @@ public class KOrder public KOrderStatus OrderStatus { get; set; } - public decimal OrderGrandTotal { get; set; } + [Required] public decimal OrderGrandTotal { get; set; } public decimal OrderGrandTotalInMainCurrency { get; set; } - public decimal OrderTotalPrice { get; set; } + [Required] public decimal OrderTotalPrice { get; set; } public decimal OrderTotalPriceInMainCurrency { get; set; } @@ -67,4 +70,6 @@ public class KOrder public KAddress OrderCompanyAddress { get; set; } public IEnumerable OrderItems { get; set; } + + public KPaymentResult OrderPaymentResult { get; set; } } diff --git a/src/Kentico.Xperience.StoreApi/Orders/KPaymentResult.cs b/src/Kentico.Xperience.StoreApi/Orders/KPaymentResult.cs new file mode 100644 index 00000000..e3ff9e58 --- /dev/null +++ b/src/Kentico.Xperience.StoreApi/Orders/KPaymentResult.cs @@ -0,0 +1,28 @@ +namespace Kentico.Xperience.StoreApi.Orders; + +public class KPaymentResult +{ + public string PaymentDate { get; set; } + + public bool PaymentIsCompleted { get; set; } + + public bool PaymentIsFailed { get; set; } + + public bool PaymentIsAuthorized { get; set; } + + public string PaymentDescription { get; set; } + + public string PaymentTransactionId { get; set; } + + public string PaymentAuthorizationID { get; set; } + + public string PaymentMethodName { get; set; } + + public int PaymentMethodID { get; set; } + + public string PaymentStatusValue { get; set; } + + public string PaymentStatusName { get; set; } + + public string PaymentApprovalUrl { get; set; } +} diff --git a/src/Kentico.Xperience.StoreApi/Orders/OrderController.cs b/src/Kentico.Xperience.StoreApi/Orders/OrderController.cs index 2a0b78d2..d1199fb9 100644 --- a/src/Kentico.Xperience.StoreApi/Orders/OrderController.cs +++ b/src/Kentico.Xperience.StoreApi/Orders/OrderController.cs @@ -25,17 +25,20 @@ public class OrderController : ControllerBase private readonly IMapper mapper; private readonly ISiteService siteService; private readonly IShoppingService shoppingService; + private readonly IOrderStatusInfoProvider orderStatusInfoProvider; public OrderController( IOrderInfoProvider orderInfoProvider, IMapper mapper, ISiteService siteService, - IShoppingService shoppingService) + IShoppingService shoppingService, + IOrderStatusInfoProvider orderStatusInfoProvider) { this.orderInfoProvider = orderInfoProvider; this.mapper = mapper; this.siteService = siteService; this.shoppingService = shoppingService; + this.orderStatusInfoProvider = orderStatusInfoProvider; } /// @@ -83,7 +86,7 @@ public async Task> CurrentCustomerOrderList([Fro /// /// Endpoint for getting list of orders based on request to display in XbyK administration. /// - /// + /// Order list request. /// [HttpGet("admin/list", Name = nameof(AdminOrderList))] public async Task> AdminOrderList([FromQuery] OrderListRequest request) @@ -93,11 +96,17 @@ public async Task> AdminOrderList([FromQuery] Or { request.OrderBy = $"{nameof(OrderInfo.OrderDate)} DESC"; } + var orderQuery = orderInfoProvider.Get() .OnSite(siteService.CurrentSite.SiteID) .Page(page, request.PageSize) .OrderBy(request.OrderBy); + if (request.CustomerId is > 0) + { + orderQuery = orderQuery.WhereEquals(nameof(OrderInfo.OrderCustomerID), request.CustomerId); + } + var orders = mapper.Map>(await orderQuery.GetEnumerableTypedResultAsync()); return Ok(new OrderListResponse @@ -107,4 +116,94 @@ public async Task> AdminOrderList([FromQuery] Or MaxPage = (orderQuery.TotalRecords / request.PageSize) + 1 }); } + + + /// + /// Returns order by ID only if order belongs to current customer. + /// + /// Order ID. + [HttpGet("detail/{orderId:int}", Name = nameof(OrderDetail))] + public async Task> OrderDetail([FromRoute] int orderId) + { + var customer = shoppingService.GetCurrentCustomer(); + if (customer == null) + { + return NotFound(); + } + + var order = await orderInfoProvider.GetAsync(orderId); + + if (order == null || order.OrderCustomerID != customer.CustomerID) + { + return NotFound(); + } + + return Ok(mapper.Map(order)); + } + + + /// + /// Returns order by ID. + /// + /// Order ID. + [HttpGet("admin/detail/{orderId:int}", Name = nameof(AdminOrderDetail))] + public async Task> AdminOrderDetail([FromRoute] int orderId) + { + var order = await orderInfoProvider.GetAsync(orderId); + + if (order == null) + { + return NotFound(); + } + + return Ok(mapper.Map(order)); + } + + + /// + /// Endpoint for listing order statuses. + /// + /// List of order statuses. + [HttpGet("statuses/list", Name = nameof(OrderStatusesList))] + public async Task>> OrderStatusesList() + { + int siteId = ECommerceHelper.GetSiteID(siteService.CurrentSite.SiteID, "CMSStoreUseGlobalOrderStatus"); + + var orderStatuses = await orderStatusInfoProvider.Get() + .OnSite(siteId, includeGlobal: siteId == 0) + .OrderBy(nameof(OrderStatusInfo.StatusOrder)) + .GetEnumerableTypedResultAsync(); + + return Ok(mapper.Map>(orderStatuses)); + } + + /// + /// Updates order. Updates all fields on order level and addresses on sub-level. Customer data and order items cannot be updated. + /// + /// Order dto + [HttpPut("update", Name = nameof(UpdateOrder))] + public async Task UpdateOrder([FromBody] KOrder order) + { + var orderInfo = await orderInfoProvider.GetAsync(order.OrderId); + if (orderInfo == null) + { + return NotFound(); + } + + orderInfo = mapper.Map(order, orderInfo); + + if (orderInfo.OrderBillingAddress.HasChanged) + { + orderInfo.OrderBillingAddress.Update(); + } + + if (orderInfo.OrderShippingAddress.HasChanged) + { + orderInfo.OrderShippingAddress.Update(); + } + + orderInfoProvider.Set(orderInfo); + + return Ok(); + } } diff --git a/src/Kentico.Xperience.StoreApi/Orders/OrderListRequest.cs b/src/Kentico.Xperience.StoreApi/Orders/OrderListRequest.cs index 795c59f2..bdcb2e2a 100644 --- a/src/Kentico.Xperience.StoreApi/Orders/OrderListRequest.cs +++ b/src/Kentico.Xperience.StoreApi/Orders/OrderListRequest.cs @@ -13,4 +13,6 @@ public class OrderListRequest public int PageSize { get; set; } public string OrderBy { get; set; } + + public int? CustomerId { get; set; } } diff --git a/src/Kentico.Xperience.StoreApi/Products/IKProductService.cs b/src/Kentico.Xperience.StoreApi/Products/IKProductService.cs index 41fc8f5e..5a6c2643 100644 --- a/src/Kentico.Xperience.StoreApi/Products/IKProductService.cs +++ b/src/Kentico.Xperience.StoreApi/Products/IKProductService.cs @@ -1,6 +1,7 @@ using Kentico.Xperience.StoreApi.Products.Categories; using Kentico.Xperience.StoreApi.Products.Pages; using Kentico.Xperience.StoreApi.Products.Prices; +using Kentico.Xperience.StoreApi.Products.SKU; namespace Kentico.Xperience.StoreApi.Products; @@ -49,4 +50,12 @@ public interface IKProductService /// Currency code. /// Task GetProductInventoryAndPrices(int skuId, string currencyCode); + + + /// + /// Get all standalone products in specific culture. + /// + /// Standalone products parameters. + /// + Task> GetStandaloneProducts(ProductRequest request); } diff --git a/src/Kentico.Xperience.StoreApi/Products/KProductService.cs b/src/Kentico.Xperience.StoreApi/Products/KProductService.cs index cfc49d78..8068498a 100644 --- a/src/Kentico.Xperience.StoreApi/Products/KProductService.cs +++ b/src/Kentico.Xperience.StoreApi/Products/KProductService.cs @@ -10,6 +10,7 @@ using Kentico.Xperience.StoreApi.Products.Categories; using Kentico.Xperience.StoreApi.Products.Pages; using Kentico.Xperience.StoreApi.Products.Prices; +using Kentico.Xperience.StoreApi.Products.SKU; namespace Kentico.Xperience.StoreApi.Products; @@ -18,6 +19,7 @@ internal class KProductService : IKProductService { private readonly IPageRetriever pageRetriever; private readonly IProductPageConverter productPageConverter; + private readonly IProductSKUConverter productSKUConverter; private readonly ISKUInfoProvider skuInfoProvider; private readonly ICatalogPriceCalculatorFactory catalogPriceCalculatorFactory; private readonly ISiteService siteService; @@ -25,12 +27,14 @@ internal class KProductService : IKProductService private readonly IShoppingService shoppingService; - public KProductService(IPageRetriever pageRetriever, IProductPageConverter productPageConverter, + public KProductService( + IPageRetriever pageRetriever, IProductPageConverter productPageConverter, IProductSKUConverter productSKUConverter, ISKUInfoProvider skuInfoProvider, ICatalogPriceCalculatorFactory catalogPriceCalculatorFactory, ISiteService siteService, IMapper mapper, IShoppingService shoppingService) { this.pageRetriever = pageRetriever; this.productPageConverter = productPageConverter; + this.productSKUConverter = productSKUConverter; this.skuInfoProvider = skuInfoProvider; this.catalogPriceCalculatorFactory = catalogPriceCalculatorFactory; this.siteService = siteService; @@ -48,10 +52,7 @@ public async Task> GetProductPages(ProductPageRequest orderBy = "DocumentSKUName ASC"; } - var productTypes = (await DataClassInfoProvider.ProviderObject.Get() - .WhereTrue(nameof(DataClassInfo.ClassIsProduct)) - .Columns(nameof(DataClassInfo.ClassName), nameof(DataClassInfo.ClassFormDefinition)) - .GetEnumerableTypedResultAsync()) + var productTypes = (await GetProductDataClasses()) .Select(p => new { p.ClassName, @@ -161,6 +162,35 @@ public async Task GetProductInventoryAndPrices(int sk } + /// + public async Task> GetStandaloneProducts(ProductRequest request) + { + var (culture, currencyCode, orderBy, limit, withVariants) = request; + + var skuInfos = skuInfoProvider.Get() + .WhereEqualsOrNull(nameof(SKUInfo.SKUOptionCategoryID), 0) + .WhereEqualsOrNull(nameof(SKUInfo.SKUParentSKUID), 0) + .TopN(limit) + .OrderBy(orderBy); + + + var query = DocumentHelper.GetDocuments() + .WhereNotNull(nameof(TreeNode.NodeSKUID)) + .Column(nameof(TreeNode.NodeSKUID)); + + if (!string.IsNullOrEmpty(culture)) + { + query = query.Culture(culture); + } + + skuInfos = skuInfos.WhereNotIn(nameof(SKUInfo.SKUID), query); + + + return (await skuInfos.GetEnumerableTypedResultAsync()) + .Select(x => productSKUConverter.Convert(x, currencyCode, withVariants, true)); + } + + private ProductPricesResponse GetProductPrices(SKUInfo sku, string currencyCode) { var calculator = catalogPriceCalculatorFactory.GetCalculator(siteService.CurrentSite.SiteID); @@ -185,4 +215,11 @@ private ProductPricesResponse GetProductPrices(SKUInfo sku, string currencyCode) return response; } + + + private async Task> GetProductDataClasses() + => await DataClassInfoProvider.ProviderObject.Get() + .WhereTrue(nameof(DataClassInfo.ClassIsProduct)) + .Columns(nameof(DataClassInfo.ClassName), nameof(DataClassInfo.ClassFormDefinition)) + .GetEnumerableTypedResultAsync(); } diff --git a/src/Kentico.Xperience.StoreApi/Products/Pages/ProductPageConverter.cs b/src/Kentico.Xperience.StoreApi/Products/Pages/ProductPageConverter.cs index 266f5307..9efdf768 100644 --- a/src/Kentico.Xperience.StoreApi/Products/Pages/ProductPageConverter.cs +++ b/src/Kentico.Xperience.StoreApi/Products/Pages/ProductPageConverter.cs @@ -30,7 +30,7 @@ public virtual TProduct Convert(SKUTreeNode skuTreeNode, IEnumerable cus { var model = mapper.Map(skuTreeNode); model.AbsoluteUrl = DocumentURLProvider.GetAbsoluteUrl(skuTreeNode); - model.SKU = skuConverter.Convert(skuTreeNode.SKU, currencyCode, withVariants); + model.SKU = skuConverter.Convert(skuTreeNode.SKU, currencyCode, withVariants, false); model.CustomFields = customFields.ToDictionary(f => f, f => skuTreeNode.GetValue(f)); if (withLongDescription) { diff --git a/src/Kentico.Xperience.StoreApi/Products/ProductPageRequest.cs b/src/Kentico.Xperience.StoreApi/Products/ProductPageRequest.cs index b26a1326..498203ee 100644 --- a/src/Kentico.Xperience.StoreApi/Products/ProductPageRequest.cs +++ b/src/Kentico.Xperience.StoreApi/Products/ProductPageRequest.cs @@ -1,14 +1,11 @@ -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - -using Kentico.Xperience.StoreApi.Currencies; +using System.ComponentModel.DataAnnotations; namespace Kentico.Xperience.StoreApi.Products; /// /// Model for product pages request used in API. /// -public class ProductPageRequest +public class ProductPageRequest : ProductRequest { /// /// Node alias path prefix. @@ -16,35 +13,6 @@ public class ProductPageRequest [Required] public string Path { get; set; } - /// - /// Document culture. - /// - [RegularExpression("[a-zA-Z]{2}-[a-zA-Z]{2}")] - public string Culture { get; set; } - - /// - /// Product currency. - /// - [CurrencyValidation] - public string Currency { get; set; } - - /// - /// Order by SQL expression. - /// - public string OrderBy { get; set; } - - /// - /// Limit how many products to return. - /// - [DefaultValue(12)] - [Range(1, 1000)] - public int Limit { get; set; } - - /// - /// If true variants are loaded too for products with variants (default false). - /// - public bool WithVariants { get; set; } - /// /// If true, DocumentSKUDescription is filled too (default false). /// diff --git a/src/Kentico.Xperience.StoreApi/Products/ProductRequest.cs b/src/Kentico.Xperience.StoreApi/Products/ProductRequest.cs new file mode 100644 index 00000000..7d4773c5 --- /dev/null +++ b/src/Kentico.Xperience.StoreApi/Products/ProductRequest.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel; + +using Kentico.Xperience.StoreApi.Currencies; + +namespace Kentico.Xperience.StoreApi.Products; + +public class ProductRequest +{ + /// + /// To determine if product has its page, culture needs to be provided + /// so documents in the specific culture will be checked. + /// + [RegularExpression("[a-zA-Z]{2}-[a-zA-Z]{2}")] + public string Culture { get; set; } + + /// + /// Product currency. + /// + [CurrencyValidation] + public string Currency { get; set; } + + /// + /// Order by SQL expression. + /// + public string OrderBy { get; set; } + + /// + /// Limit how many products to return. + /// + [DefaultValue(12)] + [Range(1, 1000)] + public int Limit { get; set; } + + /// + /// If true variants are loaded too for products with variants (default false). + /// + public bool WithVariants { get; set; } + + public void Deconstruct(out string culture, out string currency, out string orderBy, + out int limit, out bool withVariants) + { + culture = Culture; + currency = Currency; + orderBy = OrderBy; + limit = Limit; + withVariants = WithVariants; + } +} diff --git a/src/Kentico.Xperience.StoreApi/Products/ProductsController.cs b/src/Kentico.Xperience.StoreApi/Products/ProductsController.cs index c4f32a35..e02f4ac4 100644 --- a/src/Kentico.Xperience.StoreApi/Products/ProductsController.cs +++ b/src/Kentico.Xperience.StoreApi/Products/ProductsController.cs @@ -8,6 +8,7 @@ using Kentico.Xperience.StoreApi.Products.Categories; using Kentico.Xperience.StoreApi.Products.Pages; using Kentico.Xperience.StoreApi.Products.Prices; +using Kentico.Xperience.StoreApi.Products.SKU; using Kentico.Xperience.StoreApi.Routing; using Microsoft.AspNetCore.Http; @@ -146,4 +147,24 @@ public async Task> GetInventoryPrices(in return ValidationProblem(); } } + + + /// + /// Returns all standalone products. + /// + /// Product request parameters. + /// + [HttpGet("standalone-products", Name = nameof(GetStandaloneProducts))] + [AuthorizeStore] + public async Task>> GetStandaloneProducts([FromQuery] ProductRequest request) + { + if (request.Culture is not null && + !CultureSiteInfoProvider.IsCultureOnSite(request.Culture, SiteContext.CurrentSiteName)) + { + return BadRequest($"Culture '{request.Culture}' is not assigned for site"); + } + + var products = await productService.GetStandaloneProducts(request); + return Ok(products); + } } diff --git a/src/Kentico.Xperience.StoreApi/Products/SKU/IProductSKUConverter.cs b/src/Kentico.Xperience.StoreApi/Products/SKU/IProductSKUConverter.cs index b6ab646a..c3e5e76c 100644 --- a/src/Kentico.Xperience.StoreApi/Products/SKU/IProductSKUConverter.cs +++ b/src/Kentico.Xperience.StoreApi/Products/SKU/IProductSKUConverter.cs @@ -14,6 +14,7 @@ public interface IProductSKUConverter /// SKU info. /// Currency code in which evaluates prices. /// If true, variants are included. + /// If true, is included. /// - TModel Convert(SKUInfo skuInfo, string currencyCode, bool withVariants); + TModel Convert(SKUInfo skuInfo, string currencyCode, bool withVariants, bool withLongDescription); } diff --git a/src/Kentico.Xperience.StoreApi/Products/SKU/KProductSKU.cs b/src/Kentico.Xperience.StoreApi/Products/SKU/KProductSKU.cs index cddeeeb6..095e59c3 100644 --- a/src/Kentico.Xperience.StoreApi/Products/SKU/KProductSKU.cs +++ b/src/Kentico.Xperience.StoreApi/Products/SKU/KProductSKU.cs @@ -18,6 +18,8 @@ public class KProductSKU public string SKUShortDescription { get; set; } + public string SKULongDescription { get; set; } + public string SKUNumber { get; set; } public bool SKUEnabled { get; set; } diff --git a/src/Kentico.Xperience.StoreApi/Products/SKU/ProductSKUConverter.cs b/src/Kentico.Xperience.StoreApi/Products/SKU/ProductSKUConverter.cs index aeb68d6b..4a15dbbd 100644 --- a/src/Kentico.Xperience.StoreApi/Products/SKU/ProductSKUConverter.cs +++ b/src/Kentico.Xperience.StoreApi/Products/SKU/ProductSKUConverter.cs @@ -37,7 +37,7 @@ public ProductSKUConverter(ICatalogPriceCalculatorFactory catalogPriceCalculator /// - public virtual TModel Convert(SKUInfo skuInfo, string currencyCode, bool withVariants) + public virtual TModel Convert(SKUInfo skuInfo, string currencyCode, bool withVariants, bool withLongDescription) { var model = mapper.Map(skuInfo); @@ -66,6 +66,11 @@ public virtual TModel Convert(SKUInfo skuInfo, string currencyCode, bool withVar model.Variants = variants; } + if (withLongDescription) + { + model.SKULongDescription = skuInfo.SKUDescription; + } + model.Prices = mapper.Map(prices); return model; diff --git a/submodules/xperience-by-kentico-ecommerce-common b/submodules/xperience-by-kentico-ecommerce-common index cf7540fd..91a62d6b 160000 --- a/submodules/xperience-by-kentico-ecommerce-common +++ b/submodules/xperience-by-kentico-ecommerce-common @@ -1 +1 @@ -Subproject commit cf7540fddfe1e5ca7e376aaf1467d29dce65267e +Subproject commit 91a62d6bcbcde1faa8d06d5f8d5cc0fa68fc68a4 diff --git a/test/Kentico.Xperience.K13Ecommerce.IntegrationTests/StoreApiOrderTests.cs b/test/Kentico.Xperience.K13Ecommerce.IntegrationTests/StoreApiOrderTests.cs new file mode 100644 index 00000000..8bc46856 --- /dev/null +++ b/test/Kentico.Xperience.K13Ecommerce.IntegrationTests/StoreApiOrderTests.cs @@ -0,0 +1,85 @@ +using Kentico.Xperience.K13Ecommerce.StoreApi; + +namespace Kentico.Xperience.KStore.Tests; + +/// +/// Store API order integration tests. +/// +[TestFixture] +[Category("IntegrationTests")] +public class StoreApiOrderTests : StoreApiTestBase +{ + /// + /// Test for existing order. + /// + /// Order ID. + /// Customer email. + [Test] + [TestCase(27, "automation.test@test.com")] + public async Task OrderDetail_ExistingOrder(int orderId, string customerEmail) + { + var order = await StoreApiClient.OrderDetailAsync(orderId); + + Assert.That(order.OrderId, Is.EqualTo(orderId)); + Assert.That(order.OrderItems, Has.Count.EqualTo(2)); + + Assert.That(order.OrderCustomer.CustomerEmail, Is.EqualTo(customerEmail)); + Assert.That(order.OrderBillingAddress.AddressLine1, Is.EqualTo("Automation Street 1")); + Assert.That(order.OrderBillingAddress.AddressCity, Is.EqualTo("Automation City A")); + Assert.That(order.OrderBillingAddress.AddressZip, Is.EqualTo("00001")); + Assert.That(order.OrderBillingAddress.AddressCountryId, Is.EqualTo(326)); + + Assert.That(order.OrderShippingAddress.AddressLine1, Is.EqualTo("Automation Street 2")); + Assert.That(order.OrderShippingAddress.AddressCity, Is.EqualTo("Automation City B")); + Assert.That(order.OrderShippingAddress.AddressZip, Is.EqualTo("00002")); + Assert.That(order.OrderShippingAddress.AddressCountryId, Is.EqualTo(326)); + + Assert.That(order.OrderStatus.StatusName, Is.EqualTo("New")); + Assert.That(order.OrderShippingOption.ShippingOptionName, Is.EqualTo("StandardDelivery")); + Assert.That(order.OrderPaymentOption.PaymentOptionName, Is.EqualTo("DancingGoatCore.MoneyTransfer")); + } + + /// + /// Test order detail when order does not exist. + /// + /// Order ID. + [Test] + [TestCase(99999)] + public async Task OrderDetail_NonExistingOrder(int orderId) + { + // test that exception has status code 404 + var exception = Assert.ThrowsAsync(async () => await StoreApiClient.OrderDetailAsync(orderId)); + Assert.That(exception!.StatusCode, Is.EqualTo(404)); + } + + /// + /// Test for order update. + /// + /// Order ID. + [Test] + [TestCase(27)] + public async Task OrderUpdate_ChangeStatus_And_IsPaid(int orderId) + { + var order = await StoreApiClient.OrderDetailAsync(orderId); + + order.OrderStatus = new KOrderStatus { StatusId = 2 }; + order.OrderIsPaid = true; + + await StoreApiClient.UpdateOrderAsync(order); + + var updatedOrder = await StoreApiClient.OrderDetailAsync(orderId); + + Assert.That(updatedOrder.OrderStatus.StatusName, Is.EqualTo("PaymentReceived")); + Assert.That(updatedOrder.OrderIsPaid, Is.True); + + order.OrderIsPaid = false; + order.OrderStatus = new KOrderStatus { StatusId = 3 }; + + await StoreApiClient.UpdateOrderAsync(order); + + var rollbackedOrder = await StoreApiClient.OrderDetailAsync(orderId); + + Assert.That(rollbackedOrder.OrderStatus.StatusName, Is.EqualTo("New")); + Assert.That(rollbackedOrder.OrderIsPaid, Is.False); + } +}