# Unchained Engine > Headless, code-first e-commerce SDK for Node.js with GraphQL API, MCP server for AI agents, and Admin UI Copilot. This file contains all documentation content in a single document following the llmstxt.org standard. ## Assortments This section covers the management of product assortments within the Unchained e-commerce platform. An assortment represents a grouping of similar products that share a common theme, category, or purpose. Within Unchained, assortments can be organized hierarchically, with parent-child relationships that allow for deeper categorization of products. Using the admin UI you can perform task related with category management including: - Viewing and search and/or filtering them with different parameters - Add, edit and delete categories - Assign filters - Assign products - Assign media - Link assortments to create a hierarchy between them - Activate or deactivate assortment and many more ## View and filter assortments If you navigate to the assortments page, you will see a list of categories that are available in the shop. By default, only active root assortments will be visible, but you can change this by using the toggle button to display inactive assortments as well. Furthermore, there is an option to view assortments as a graph, which provides a better understanding of their hierarchy. In addition to these features, you can search, filter, and sort assortments using different parameters, such as tags, name, and status. This will help you quickly locate the assortment you are looking for and make managing your inventory more efficient. ![diagram](../assets/assortment-list.png) ## Add, edit and delete assortment 1. ### Adding new Assortment If you want to create a new assortment, you can do so by clicking the "Add" button on the list view of assortments. This will take you to the assortment creation form page, where you can provide all the required information, including the name of the assortment, a brief description, and any other relevant details that are necessary. This form is easy to use and will guide you through the process of creating a new assortment quickly and efficiently. ![diagram](../assets/assortment-form.png) 2. ### Edit assortment To modify an existing assortment, simply select the assortment from the list view, and you will be taken to its detail view page. From there, you can make changes to the assortment's information, including adding localized counterparts for each. If you want to add a localized text for an assortment, you will need to [add the language](./language/#add-language) first by going to the add the [language page](./language). This step is important to ensure that your assortment is accurately translated and localized for your customers in different regions. Once you have added the language, you can proceed to add the localized text for your assortment, making it more accessible and understandable for your global audience. 3. ### Delete assortment If you have the necessary privileges, you can delete an assortment by using the delete button found on the assortment detail page. However, it is essential to be extra cautious when performing this operation, as it is not reversible. Deleting an assortment that is linked to other parts of the shop can have unintended consequences and lead to problems. Therefore, it is important to ensure that there are no dependencies on the assortment before proceeding with the deletion. If you are unsure about whether it is safe to delete an assortment, it is always a good idea to consult with a supervisor or someone with more experience before taking any action. ![diagram](../assets/assortment-text-setting.png) ## Activating or deactivating assortment When you access an assortment's detail page, you will see a button that displays the current status of the assortment. This button can be used to toggle the assortment's status between active and inactive. Simply click on the button to change the status. This feature is useful when you need to temporarily remove an assortment from the shop or reactivate an inactive assortment. ![diagram](../assets/assortment-activate-deactivate.png) ## Root or leaf If you need to change the placement of an assortment from root to leaf or vice versa, you can use the button that displays the current placement value to toggle to the other value. This feature allows you to modify the hierarchy of your assortments and organize them in a way that best suits your business needs. Simply click on the button to change the placement value, and the assortment will be moved accordingly. This is a convenient way to reorganize your assortments and ensure that they are properly classified and easy to navigate. ![diagram](../assets/assortment-leaf-and-root-toggle.png) ## Make base Base assortments are top-level assortments that cannot be assigned as child assortments of another assortment. If an assortment was not initially added as a base assortment, you can make it base by navigating to the assortment detail page. From there, you can select the "Make Base" option, which will promote the assortment to a base assortment. This is useful when you need to restructure your assortments or if you have new assortments that need to be classified as base. Keep in mind that once an assortment has been made base, it cannot be assigned as a child of another assortment. Therefore, it is important to carefully consider your assortment structure and hierarchy before making any changes. ## Edit assortment tags You can easily manage the tags assigned to an assortment using the tag section found at the top of the assortment detail page. From there, you can add or delete tags as needed to ensure that your assortment is properly classified and easy to find. This feature is useful when you need to quickly update the tags associated with an assortment or when you want to make sure that your assortments are properly organized and categorized. Simply click on the tag section to access the tag management options, and you can easily add or delete tags as needed. ![diagram](../assets/assortment-tag-setting.png) ## Manage assortment Medias To manage media files associated with an assortment, navigate to the "Media" tab on the assortment detail page. From there, you can upload, edit, and delete media files as needed. An assortment can have multiple media files assigned to it, which can include images, videos, or other types of media. Additionally, you can add localized text for each media file to ensure that it is properly translated and accessible for your customers in different regions. This feature is useful when you need to showcase your assortment and provide customers with visual information about your products. Simply click on the "Media" tab to access the media management options, and you can easily upload, edit, or delete media files as needed. ![diagram](../assets/assortment-media-setting.png) ## Manage assortment Filters To manage filters associated with an assortment, navigate to the "Filter" tab on the assortment detail page. From there, you can add or remove links to filters that are relevant to your assortment. This feature is useful when you want to make it easier for customers to find products within a specific category or with certain attributes. Simply click on the "Filter" tab to access the filter management options, and you can easily add or remove links to filters as needed. Keep in mind that filters need to be created before they can be linked to an assortment. If you haven't created any filters yet, you can do so by following the instructions provided in the [Filter](./filter) documentation. ![diagram](../assets/image.png) ## Manage assortment Links To manage links to other assortments associated with an assortment, navigate to the "Links" tab on the assortment detail page. From there, you can add or remove links to other assortments as needed. This feature is useful when you want to create a hierarchy of related products or categories, and make it easy for customers to navigate through your assortment. Keep in mind that a non-root assortment can be assigned as a child to multiple assortments, as long as it is not a base assortment type. However, you should avoid creating cyclic relationships, where two or more assortments are part of the same link parent hierarchy. This type of relationship is not currently supported by Unchained, and can lead to unexpected issues. Note that base assortments can only have children, but cannot be assigned as a child to another assortment. This is because base assortments are top-level categories that cannot be part of a hierarchy themselves. In summary, the "Links" tab allows you to create and manage relationships between different assortments, which can help you create a more organized and intuitive shopping experience for your customers. ``` //Not allowed X a -> b -> c -> a //Allowed a -> b -> c a -> c ``` ![diagram](../assets/assortment-link-setting.png) ## Manage assortment Products To add products to an assortment, navigate to the "Products" tab on the assortment detail page. From there, you can assign multiple products to the assortment, which can help customers easily find the products they're interested in. It's important to note that a product can belong to multiple categories, so there is no restriction on assigning a product to multiple assortments. This can help you create a more flexible and intuitive product catalog, where customers can find products based on their needs and interests, rather than being limited by a strict category structure. By assigning products to assortments, you can also help customers discover related or complementary products, which can increase sales and customer satisfaction. Keep in mind that products need to be created before they can be assigned to an assortment. If you haven't created any products yet, you can do so by following the instructions provided in the [Products](./products) documentation. ![diagram](../assets/assortment-product-setting.png) --- ## Authentication Unchained engine offers several authentication flows to users to register and access the platform. The admin UI is designed to support most of these authentication and authorization flows. However, some authentication flows are more suitable for a storefront than a control panel, and hence, the admin UI may not support them. # Registration Users can create an account by clicking on the sign up button found on the log in page using either [web authentication](https://webauthn.guide/) or normal email and password registration flow To create a user account, users can click on the "sign up" button located on the login page. Two registration options are available: 1. [Web Authentication](https://webauthn.guide/): This method allows users to register their account using their device's built-in security features, such as a fingerprint scanner or facial recognition. This provides an added layer of security, as it eliminates the need for a password. 2. Email and Password Registration Flow: Users can also choose to create an account using the traditional email and password registration process. They will need to enter a valid email address and a secure password of their choice to proceed. It is important to note that both registration options require users to provide valid and accurate information to create an account. Additionally, users should be advised to choose a strong and unique password to protect their account from unauthorized access. ## Email and password registration To create a user account, users need to provide their email and choose a strong and unique password. An optional username may also be provided, depending on the platform's policies. When creating an account, it is essential that users choose a unique and valid email address to ensure that they receive necessary communications and account verification emails. Additionally, users should create a strong and unique password that is difficult for others to guess or hack. Passwords should be a combination of uppercase and lowercase letters, numbers, and special characters. In some cases, platforms may also offer the option for users to choose a username instead of using their email address. If provided, the username should also be unique and not already in use by another user on the platform. Overall, when creating an account, it is important to provide accurate and valid information, as well as to adhere to the platform's policies and terms of service. Users should also be aware of the platform's security measures and how their personal information will be stored and protected. ![diagram](../assets/sign-up-form.png) ## Web Authn registration Unchained supports one of the latest and most safest registration method. [Web authn](https://webauthn.guide/) is a strong authentication system that enables you to authenticate your serf to the system using a hardware key, finger print or retina that is unique to you only. this also means that your password will not be store in the engine unlike the traditional methods. To register using [Web authn](https://webauthn.guide/) go to the sign up page and select the **use authenticator** toggle. After that all you need to provide is a unique username that is not already used by another user in the system. when you submit a username you will be asked to authenticate yourself using any method (finger print, retina, hardware key, etc...) and on successful registration you will be redirected to the home page as a logged in automatically. ![diagram](../assets/create-user-authenticator.png) # Authentication To access the admin UI, users need to authenticate themselves by providing their credentials. There are two options for authentication: email and password, OpenID Connect and [Web authn](https://webauthn.guide/). ## Login with Web authn Alternatively, users can choose to authenticate themselves using [Web authn](https://webauthn.guide/), provided that it has been enabled. This authentication method uses public key cryptography to authenticate users, and it is considered more secure than traditional authentication methods. Users will be required to follow the prompts and provide the necessary information to complete the authentication process. In summary, users can authenticate themselves on admin UI using either email and password or with [Web authn](https://webauthn.guide/) (if enabled). --- ## Country Adding multiple country support to your e-commerce can increase your customer base and allow you to reach a wider audience. With the Admin UI, you can easily manage and activate different countries on your system, from products to plugins. The Admin UI provides a user-friendly interface for managing countries on your system. You can easily view all the countries and search and/or filter them to find the country you are looking for. Once you have found the country, you can perform the following actions: - Add New Country: You can add a new country to your system by providing the necessary information such as the country name, ISO code, and currency. - Update Existing Country: You can update the details of an existing country such as the country name, ISO code, and currency. - Delete Country: You can delete a country from your system if it is no longer needed. - Activate or Deactivate Country: You can activate or deactivate a country depending on your business needs. When you activate a country, it will be available on the entire system from products to plugins. Conversely, when you deactivate a country, it will no longer be available on the system. By using the Admin UI, you can easily manage and activate multiple countries on your e-commerce platform, making it accessible to a wider audience. ## View supported countries When you navigate to the Countries page using the link in the navigation menu, you will be able to view all the countries that have been added to the system. The page will display a list of the currently added countries, along with their status. You can easily filter the list of countries by their status, such as "Active" or "Inactive", or you can search for a specific country using the search bar provided on the page. This makes it easy to find the country you are looking for, and to manage the status of each country on the system. Overall, the Countries page provides an efficient and user-friendly way to manage the countries on your system, allowing you to easily view and filter them by their status or search for a specific country. ![diagram](../assets/countries-list.png) ## Add Country To add a new country to your e-commerce store, you need to go to the Countries page and click on the "Add" button. This will take you to a form where you can enter the details of the new country you want to support. In the form, you will be prompted to enter the ISO code of the country you want to add. Once you have entered the ISO code, you can submit the form and the new country will be added to the system. After submitting the form, you will be redirected to the newly added country detail page, where you can view and manage the details of the new country. Overall, adding a new country to your e-commerce store is a simple process that can be completed in just a few steps using the Admin UI. By supporting multiple countries, you can expand your customer base and increase your sales potential. ![diagram](../assets/new-country-form.png) ## Update Country On the Countries list, you can click on the edit icon next to a country to view its detail information or update it. This will take you to a page where you can edit the details of the selected country. In the edit page, you can view and modify the details of the country, such as its name, status, and ISO code. You can also activate or deactivate the country by changing its status. However, it is important to note that changing the ISO code of a country is not recommended, as it may cause data integrity issues. This is because the ISO code may be used in other parts of the system, such as product catalogs, shipping rates, and payment gateways. Therefore, before updating the ISO code or any other details of a country, you should ensure that the change will not cause any data integrity issues in other parts of the system. It is always a good practice to review the potential impact of any changes before making them, to avoid any unintended consequences that could affect the overall performance of your e-commerce platform. ![diagram](../assets/edit-country.png) ## Delete Country You can delete a country from your e-commerce store in two ways: either from the list view of the Countries page or from the detail page of the selected country. To delete a country from the list view of the Countries page, you can simply click on the delete icon next to the country you want to remove. A confirmation dialog box will appear, asking you to confirm the deletion. Once you confirm, the country will be permanently deleted from the system. Alternatively, you can delete a country from the detail page of the selected country. To do this, you need to open the detail page of the country and click on the delete button. Again, a confirmation dialog box will appear, asking you to confirm the deletion. Once you confirm, the country will be permanently deleted from the system. It is important to note that deleting a country is not a reversible operation and may cause data integrity issues in other parts of the system. Therefore, before deleting a country, you should review the potential impact of the deletion on other parts of the system to ensure that it will not cause any data integrity issues. It is always a good practice to make sure you have a backup of your data before making any significant changes to your e-commerce platform. --- ## Currency The e-commerce platform and Admin UI provide a user-friendly interface to enable multiple currency support, including blockchain tokens. Once you add a currency and activate it, it can be used throughout the entire system, from product pricing to order processing. If you add a cryptocurrency, you have the option to configure a contract address, which can be used to distinguish tokens on a blockchain. This information can be read by a cryptocurrency payment plugin, such as [Cryptopay](../plugins/payment/cryptopay), to determine which currency is being used for a particular transaction. In the Admin UI, you can perform the following currency-related actions: - View all the currencies and search/filter them by their name or status. - Add new currencies or tokens to the system. - Update existing currencies, including changing their name, symbol, contract address (for cryptocurrencies), and status. - Delete currencies from the system. However, this action is irreversible, and you should ensure that deleting a currency will not cause any data integrity issues. - Activate or deactivate currencies as needed, which will enable or disable them in the system. In summary, the Admin UI provides a convenient way to manage currencies and tokens, making it easy for e-commerce store owners to support a variety of payment methods and streamline their order processing workflows. ## View supported currencies When you navigate to the currencies page using the link in the navigation menu, you will be able to see a list of all the currencies that have been added to the system. You can filter these currencies by their status or search for a specific currency using the search bar. This makes it easy to locate and manage the currencies that you need for your e-commerce store. ![diagram](../assets/currencies-list.png) ## Add new currency To add a new currency to your e-commerce store, you can navigate to the currencies page and click on the "Add" button. This will bring up a form where you can enter the necessary details for the currency you want to add. For regular currencies, you will need to enter the ISO code for the currency, which is a three-letter code that represents the currency on the global market. After submitting the form, you will be redirected to the detail page for the newly added currency. However, if you want to add an ERC token as a currency, you will need to provide the token's contract address and precision using the inputs provided in the form. The contract address is a unique identifier for the token on the blockchain, while the precision determines how many decimal places are used to represent the token's value. After submitting the form, you will be redirected to the detail page for the new ERC token currency. By providing a simple and intuitive form for adding new currencies, the Admin UI makes it easy to manage multiple currencies and tokens for your e-commerce store. ![diagram](../assets/new-currency-form.png) ## Update currency To view or update the details of a currency on the currencies list, you can click on the edit icon next to the currency you want to modify. This will take you to the currency detail page, where you can view and update the currency's information. On the detail page, you will see the current values for the currency's contract address, status, and ISO code. If you need to update any of these values, you can do so by making changes in the provided input fields. However, as a precaution, it is not recommended to change the ISO code or contract address unless absolutely necessary, as doing so may cause data integrity issues throughout the system. Before making any changes to the currency information, be sure to carefully consider the potential impact on other parts of the system, and consult with any relevant stakeholders or experts to ensure that the change will not cause any unintended consequences or issues with data integrity. ![diagram](../assets/edit-currency.png) ## Delete currency To delete a currency in your e-commerce store, you can perform the deletion from two places within the Admin UI: 1. Currencies List View: You can navigate to the currencies list view and locate the currency you want to delete. Then, click on the delete icon next to the currency to initiate the deletion process. 2. Currency Detail View: Alternatively, you can open the detail page of the currency you want to delete and click on the delete button at the bottom of the page to start the deletion process. However, before deleting a currency, you should exercise caution and ensure that the deletion will not cause any data integrity issues in your system. This is because the delete operation is irreversible, and deleting a currency that is currently in use by products or order pricing plugins can lead to data inconsistencies and errors in your e-commerce store. To avoid data integrity issues, you should first check if the currency you want to delete is being used by any product or order pricing plugin in your system. If it is being used, you should update the product or pricing plugin to use a different currency before proceeding with the deletion. Once you have confirmed that deleting the currency will not cause any data integrity issues, you can initiate the deletion process from either the currencies list view or the currency detail view in Admin UI. --- ## Delivery provider To manage the delivery providers supported in your e-commerce shop, you can navigate to the delivery provider page in the system settings section of the admin UI. This page allows you to manage all the configured delivery plugins in Unchained. Using the delivery provider page, you can perform the following actions: - **View and filter delivery providers**: The page displays all the delivery providers that are currently configured in your e-commerce shop. You can also filter the delivery providers by various criteria, such as provider name or status. - **Add new delivery provider**: You can add new delivery providers to your e-commerce shop by selecting the provider you want to use and configuring the necessary settings. - **Update existing delivery provider**: You can update the settings for an existing delivery provider, such as changing the delivery method or updating the credentials required for the provider to function correctly. - **Delete delivery provider**: If a delivery provider is no longer needed, you can delete it from your e-commerce shop. By using the delivery provider page in the admin UI, you can easily manage the delivery providers supported in your e-commerce shop. This allows you to offer your customers a wide range of delivery options and ensures that deliveries are processed smoothly and efficiently. Please note that before you can add a new delivery provider to your e-commerce shop using the admin UI, it must be configured and loaded into the Unchained engine instance that your shop is using. This means that you will need to have the necessary plugin files and configurations in place before you can start managing the delivery provider through the admin UI. Furthermore, it is important to understand that the activation and deactivation of delivery providers is typically controlled by the plugin logic itself. This means that activating or deactivating a delivery provider in the admin UI does not necessarily guarantee that it will be functional in your e-commerce shop. You will need to make sure that the plugin code and configurations are properly set up and maintained to ensure the smooth functioning of your delivery providers. ## View and Filter delivery providers To view and filter delivery providers in your e-commerce shop, you can navigate to the delivery provider page in the system settings section of the admin UI. This page allows you to manage all the configured delivery plugins in Unchained. On the delivery provider page, you can perform the following actions: - **View and filter delivery providers**: The page displays all the delivery providers that are currently configured in your e-commerce shop. You can also filter the delivery providers by various criteria, such as provider name or status. To filter delivery providers by type, simply select the desired type from the drop-down filter menu. This will show only the delivery providers that match the selected type. By using the delivery provider page in the admin UI, you can easily view and filter the delivery providers in your e-commerce shop. This allows you to manage your shipping options and ensure that your customers have access to a wide range of delivery options. Please note that before you can view or filter delivery providers using the admin UI, they must be configured and loaded into the Unchained engine instance that your shop is using. This means that you will need to have the necessary plugin files and configurations in place before you can start managing the delivery providers through the admin UI. ![diagram](../assets/delivery-provider-list.png) ## Add new delivery providers To add a new delivery provider to your e-commerce shop, navigate to the delivery provider page in the system settings section of the admin UI. From there, click the "add" button found in the delivery providers list. On the new delivery provider form, you will be prompted to enter the necessary information for the new provider. This includes the provider type and adapter, where the adapter refers to the DeliveryProvider adapter plugin that is configured and loaded in the Unchained engine instance that your shop is using. After submitting the form, you will be redirected to the newly created delivery provider detail page. From there, you can further configure the settings for the new provider or manage its functionality in other ways. ![diagram](../assets/new-delivery-provider-form.png) ## Update delivery providers To make changes to a specific delivery provider, go to the list of delivery providers in the admin UI and click on the edit icon next to the provider you want to modify. This will take you to the detail page for that provider, where you can make changes to its configuration. In the detail page, you will see various fields related to the delivery provider, such as its name, type, and adapter. You can modify these fields as needed, and then save your changes by clicking the "Save" button. If there are any configuration errors with the provider, you will see a red "X" check mark next to the affected field(s), along with any relevant error messages. These errors must be resolved before you can save the changes and successfully update the delivery provider. Keep in mind that making changes to a delivery provider's configuration can have significant effects on your e-commerce shop's delivery functionality. Make sure to thoroughly test any changes before deploying them to a live production environment. ![diagram](../assets/edit-delivery-provider.png) ## Delete delivery providers To delete a delivery provider in Unchained, you have two options: - From the delivery provider list view: Navigate to the delivery provider page in the system settings section of the admin UI. Locate the delivery provider you want to delete from the list and click on the delete button next to it. - From the delivery provider detail page: Open the detail page of the delivery provider you want to delete, and click on the delete button located at the bottom of the page. Before deleting a delivery provider, it's important to consider any potential implications that may arise from the deletion. Deleting a delivery provider may result in the loss of historical data associated with that provider, such as past orders or shipping information. Therefore, it's crucial to make sure that the deletion won't cause any data integrity issues. Additionally, once a delivery provider is deleted, the operation is not reversible. This means that it's essential to exercise caution when deleting a delivery provider to avoid any unintended consequences. --- ## Events To access the event browser in the Admin UI, navigate to the "Events" tab in the main navigation menu. From there, you can view a list of all events that have been emitted by the system, sorted by date and time. You can also use the search bar to filter events by keyword. Each event in the list displays a summary of the event's payload, including the event type, timestamp, and any relevant data associated with the event. You can click on an event to view more detailed information about it, including the full payload of the event and any related events that may have been emitted as a result. The event browser is a powerful tool for tracking and auditing changes made to your system, and can be used to diagnose issues, identify trends, and troubleshoot problems. By providing a comprehensive view of all events emitted by the system, the Admin UI makes it easy to stay informed and in control of your online store. ## View emitted event In the Admin UI, you can easily browse and view all of the events by navigating to the **Activities** tab and selecting `Events`. From there, you can filter events by type or search for a specific event using the search bar. Each event in the list includes details such as the event type, creation time, and a summary of the payload. You can also click on an event to view more detailed information about it, including the full payload and any related events. ![diagram](../assets/events-list.png) ## Detail event view If you click on an event from the list you are able to view details events payload ![diagram](../assets/event-detail.png) --- ## Filter Filters are an essential feature in an e-commerce shop that allows customers to easily access products and categories. Unchained provides built-in support for defining filters for categories and products. The admin UI offers a user-friendly interface for managing filters, allowing you to create a customized browsing experience for your customers. With the filter management interface, you can perform the following actions: - **View existing filters**: The interface allows you to view all the filters that have been defined for your e-commerce shop. - **Add new filters**: You can add new filters to categories and products, making it easier for customers to find specific items. - **Add filter options**: The filter management interface allows you to add filter options for each filter, allowing customers to narrow down their search results. - **Update existing filters**: You can update the existing filters to modify their settings or add more filter options. - **Update filter options**: You can update filter options to add or remove them as required. - **Delete filters**: You can delete filters that are no longer required for your e-commerce shop. - **Delete filter options**: You can delete filter options that are no longer relevant or useful. - **Activate/deactivate filters**: You can activate or deactivate filters as needed, depending on their relevance and usefulness to your customers. By using the filter management interface in Unchained, you can create a streamlined and intuitive browsing experience for your customers. This makes it easier for them to find the products they are looking for, leading to increased customer satisfaction and sales. To make a filter available for a specific category in your e-commerce shop, you need to link it to the category by accessing the [assortment detail](./assortment) page. Until this linking process is completed, the filter will not be usable for that particular category. To link a [ilter to a category](./assortment/#manage-assortment-filters), follow these steps: - Go to the assortment detail page for the category you want to link the filter to. - In the "Filters" section of the page, click on the "Add filter" button. - Select the filter you want to link from the dropdown list of available filters. Once you have linked the filter to the category, it will be available for customers to use when browsing products in that category. You can repeat this process for each category where you want the filter to be available. ## View filters To manage the filters in your e-commerce shop, you can navigate to the "Filters" page in the admin UI. This page provides an overview of all the filters that are currently available in your shop. Using the "Filters" page, you can perform the following actions: - View existing filters: The page displays all the filters that have been defined in your e-commerce shop, along with their name, status, and language. - Search and filter filters: You can search and/or filter the filters by various criteria, such as filter name or status, to find the ones you need more easily. - Change the language of filters: If your e-commerce shop supports multiple languages, you can change the language of the filters to make them more accessible to customers who speak different languages. By using the "Filters" page in the admin UI, you can easily manage and update the filters in your e-commerce shop. This allows you to provide a more customized and streamlined browsing experience for your customers, making it easier for them to find the products they are looking for. By navigating to the "Filters" page in your shop's admin UI, you can view all the filters that currently exist in your shop. You can search and/or filter the filters by various criteria, such as filter name or status, and change the language of the filter. ![diagram](../assets/filters-list.png) ## Add filter To add a new filter in your e-commerce shop, go to the filters list page and click on the "add" button. This will present a form where you can input the following details: - **Title**: The name or title of the filter. - **Key**: A unique identifier for the filter. - **Type**: The type of filter you want to create, which can be one of the following: - `SWITCH`: A boolean/toggle type filter. - `SINGLE_CHOICE`: A filter with options that behave like radio buttons, where only one option can be selected at a time. - `MULTIPLE_CHOICE`: A filter with options that can be applied together to filter content. - `RANGE`: A filter that allows users to select a range of values, such as a date range filter. - **Options**: The filter options that will be applied to filter content. Depending on the type of filter, there can be one or multiple options. ![diagram](../assets/new-filter-form.png) ## View and edit filter To view or edit a specific filter, simply click on it from the list on the filters page. From there, you can also add localized titles and subtitles to the filter if your shop is localized. However, to add localized text for a specific language, you must first [add the language](./language/#add-language) by going to the [language page](./language). ![diagram](../assets/filter-detail-text.png) ## Activate/deactivate filter To manage the status of a filter, go to the filter detail page and locate the button at the top right corner that displays the current status. This button can be used to toggle the status of a filter between active and inactive, depending on its current status. Ensuring the proper status of your filters is important for managing your e-commerce shop effectively. Active filters will be available for customers to use when searching for products, while inactive filters will not be visible to customers. ![diagram](../assets/filter-activate-deactivate.png) ## View and edit filter options The option tab can be accessed from the filter detail page, and it allows you to make changes to the title and subtitle of a filter option. If your e-commerce shop is set up for multiple languages, you can also add localized text for specific languages to your filter options. Keep in mind that you must first [add the desired language](./language/#add-language) by going to the [language](./language) page before adding localized text. ![diagram](../assets/filter-edit-option.png) ## Add filter option To add more options to a filter that has already been created, you can go to the options tab on the filter detail page. ![diagram](../assets/filter-add-option.png) ## Delete filter To delete a filter, there are two options available. The first option is to delete the filter from the list view on the filters page. The second option is to delete the filter from the detail page of the specific filter. However, before deleting a filter, ensure that deleting it won't cause any integrity issues, as the operation is not reversible. --- ## Language Adding multiple language/locale support to your e-commerce store is made easy with the Admin UI's user-friendly interface. Once you add and activate a language, you can use it throughout your system, from product and assortment descriptions to localized filter texts. In the Admin UI, you have the following options for managing languages: - View all currently added languages and search/filter them as needed. - Add new languages with their corresponding ISO codes. - Update existing languages with new information or change their status. - Delete languages from your store. Please note that this operation is not reversible, so be sure to verify that it will not cause any data integrity issues before deleting a language. - Activate or deactivate languages depending on your current needs ## View supported Language To view all the currently added languages and filter or search them, navigate to the "Languages" page using the link in the navigation bar. Once on the "Languages" page, you can perform the following actions using the Admin UI: - Add a new language: click on the "Add" button and use the form provided to add a new language to your e-commerce store. - Update an existing language: click on the "Edit" icon for the language you want to update and use the form provided to modify the language's information. - Delete a language: click on the "Delete" icon for the language you want to delete. Be sure to confirm the action as it is irreversible. ![diagram](../assets/language-list.png) ## Add Language To add a new language in your e-commerce store, follow these steps: 1. Go to the Admin UI and navigate to the Languages page using the link in the navigation menu. 2. Click on the "Add" button to add a new language. 3. You will be redirected to a form where you can add the language ISO code. 4. After submitting the form, you will be redirected to the newly added language detail page. ![diagram](../assets/new-language.png) ## Update Language When you navigate to the languages list, you will see a list of all the languages currently added in your e-commerce store. To edit the details of a language, click on the edit icon. This will take you to the language detail page where you can view and update the language information, including the language's status and iso code. Note that changing the iso code of a language is not recommended as it may have been used in other parts of the system, and changing it may cause data integrity issues. Therefore, it is important to ensure that any changes made do not compromise the integrity of the data. ![diagram](../assets/edit-language.png) ## Delete Language To delete a language, you can do so from two locations: the list view of the languages page or the detail page of a specific language. Before deleting a language, it is important to ensure that this action will not cause any integrity issues as it cannot be undone. Be sure to consider any potential implications before deleting a language. --- ## Orders Orders are a crucial part of any E-commerce site, and Unchained Admin UI offers comprehensive functionalities for viewing and managing orders in your shop easily. The following functionalities are provided in Admin UI for managing orders: - View all orders with search and filter support - Track the status of a given order - Confirm pending orders - Manually mark an order as paid (Automation is supported by Unchained) - Manually mark an order as delivered (Automation is supported by Unchained) - Manually reject an order (Automation is supported by Unchained) - Delete pending orders - View all the details stored for a specific order, including the customer's information, order items, payment information, shipping information, and order history. By using the order management functionalities provided in Admin UI, you can efficiently manage your orders and ensure that your customers receive their products on time. ## View and filter orders To view all orders in your shop, including carts, you can navigate to the "Orders" page in the admin UI. From there, you can use the search and filter capabilities to find specific orders. If you want to include carts in the list of orders, simply toggle the "Show Carts" button. You can also search for a specific order using its "orderNumber". Once you find the order you are looking for, you can view all the details associated with it, such as the customer's information, payment details, and order status. ![diagram](../assets/orders-list.png) ## Order details The order detail page in the admin UI provides a comprehensive view of all the information related to an order. Depending on the current status of the order, certain actions can be performed. If the order has an **OPEN** status, you can delete it using the "Delete" button. For orders with a **PENDING** status, you can either mark them as confirmed using the "Confirm" button or reject them using the "Reject" button. Once an order has been confirmed or rejected, these actions cannot be undone. Additionally, if the order has a PAID status, you can change its payment status to **PAID** using the "Mark as Paid" button. Similarly, if the order has a **DELIVERED** status, you can change its delivery status to **DELIVERED** using the "Mark as Delivered" button. It's important to note that these actions are available only based on the current status of the order, and attempting to perform an action that's not applicable to the order's status will not be possible. Furthermore, once an action has been performed on an order, it cannot be reversed. ![diagram](../assets/order-detail.png) --- ## Overview The Unchained Commerce Admin UI is a powerful tool that provides merchants with an easy-to-use interface to manage their online store. With the Admin UI, merchants can perform a variety of tasks that are essential to running an e-commerce business. One of the key features of the Admin UI is the ability to manage products. Merchants can use the UI to add new products, edit existing products, and delete products that are no longer available. The Admin UI also provides a way to organize products into categories, which can help customers find what they're looking for more easily. In addition to managing products, the Admin UI also provides merchants with the ability to manage orders. Merchants can view all orders in one place, track the status of individual orders, and perform various actions on orders, such as confirming, rejecting, marking as paid, and marking as delivered. Merchants can also view detailed information about each order, such as the customer's name and address, the products ordered, and the order total. The Admin UI also includes features for managing customers. Merchants can view all customers in one place, view detailed information about each customer, and perform actions such as editing customer information and adding new customers. The Admin UI is designed to be intuitive and user-friendly, with a clean and simple interface that makes it easy for merchants to find the information they need and perform the tasks they need to do. The UI is also highly customizable, with a range of settings and options that allow merchants to tailor it to their specific needs. Overall, the Unchained Commerce Admin UI is an essential tool for any merchant looking to run a successful e-commerce business. With its wide range of features and intuitive interface, it provides merchants with everything they need to manage their store efficiently and provide a great shopping experience for their customers. ![diagram](../assets/home.png) --- ## Payment provider To manage the payment providers supported in your e-commerce shop, you can navigate to the payment provider page in the system settings section of the admin UI. This page allows you to manage all the configured payment plugins in Unchained. Using the payment provider page, you can perform the following actions: - **View and filter payment providers**: The page displays all the payment providers that are currently configured in your e-commerce shop. You can also filter the payment providers by various criteria, such as provider name or status. - **Add new payment provider**: You can add new payment providers to your e-commerce shop by selecting the provider you want to use and configuring the necessary settings. - **Update existing payment provider**: You can update the settings for an existing payment provider, such as changing the payment method or updating the credentials required for the provider to function correctly. - **Delete payment provider**: If a payment provider is no longer needed, you can delete it from your e-commerce shop. By using the payment provider page in the admin UI, you can easily manage the payment providers supported in your e-commerce shop. This allows you to offer your customers a wide range of payment options and ensures that payments are processed smoothly and securely. Please note that before you can add a new payment provider to your e-commerce shop using the admin UI, it must be configured and loaded into the Unchained engine instance that your shop is using. This means that you will need to have the necessary plugin files and configurations in place before you can start managing the payment provider through the admin UI. Furthermore, it is important to understand that the activation and deactivation of payment providers is typically controlled by the plugin logic itself. This means that activating or deactivating a payment provider in the admin UI does not necessarily guarantee that it will be functional in your e-commerce shop. You will need to make sure that the plugin code and configurations are properly set up and maintained to ensure the smooth functioning of your payment providers. ## View and Filter payment providers In the payment provider page of your e-commerce shop's admin UI, you can view and filter the payment providers that have been added to your shop based on their type. This can be useful when you have a large number of payment providers and want to quickly find providers of a particular type. To view and filter payment providers by type, follow these steps: - Navigate to the payment provider page in the admin UI. - Look for the "Type" column in the list of payment providers. This column displays the type of each payment provider, such as "Credit Card", "PayPal", "Bank Transfer", etc. - Click on the "Type" column header to sort the payment providers by type. - Use the search bar or filter options available on the page to filter the payment providers by type. For example, you could filter the list to only display payment providers of the "Credit Card" type. By viewing and filtering payment providers by type, you can easily find and manage the payment providers that are relevant to your e-commerce shop's needs. This can help you offer a wider range of payment options to your customers, improving their shopping experience and increasing the chances of successful transactions. ![diagram](../assets/payment-provider-list.png) ## Add new payment providers o add a new payment provider plugin to your e-commerce shop, follow these steps: - Navigate to the payment provider page in the admin UI. - Click on the "Add" button found in the payment providers list. - On the new payment provider form, enter the required information such as the type and adapter for your payment provider. The adapter refers to the paymentProvider adapter plugin that has been configured and loaded in the Unchained engine. - Once you have entered all the necessary information, submit the form. - You will be redirected to the newly created payment provider detail page. On the payment provider detail page, you can view and manage the settings for the newly added payment provider. You can update the provider's settings, such as the payment method and credentials required for it to function correctly. You can also activate or deactivate the provider, depending on your needs. Adding new payment provider plugins can help you expand the payment options available to your customers, improving their shopping experience and increasing the chances of successful transactions. By using the admin UI to manage your payment providers, you can easily add, update, and activate/deactivate payment providers as needed. ![diagram](../assets/new-payment-provider.png) ## Update payment providers If you need to update the settings of a specific payment provider in your e-commerce shop, you can do so by clicking on the edit icon (usually represented by a pencil or similar icon) next to that payment provider in the payment provider list view. This will take you to the payment provider detail page, where you can edit various settings for the payment provider. One of the main settings you can edit for a payment provider is the configuration. The configuration settings will depend on the specific payment provider you are working with, but may include things like API keys, merchant IDs, and other credentials needed to connect to the payment provider's services. If there is an error with the configuration settings for a payment provider, you will see a red "X" check mark next to the configuration field(s) with the error, along with helpful error text explaining what needs to be corrected. This can help you quickly identify and fix any configuration errors, ensuring that the payment provider functions correctly and smoothly in your e-commerce shop. By using the edit function in the payment provider list view, you can easily manage the settings for each payment provider in your shop, making it easy to keep your payment options up-to-date and functioning properly. ![diagram](../assets/edit-payment-provider.png) ## Delete payment providers To delete a payment provider from your e-commerce shop, there are two places where you can do so: - Payment Provider List View: You can delete a payment provider by clicking on the delete icon (usually represented by a trash can or similar icon) next to that payment provider in the payment provider list view. - Payment Provider Detail Page: Alternatively, you can also delete a payment provider by opening the detail page for that provider and clicking on the "Delete" button. Before you delete a payment provider, however, it's important to ensure that your change won't cause any integrity issues with your shop's data. Deleting a payment provider is an irreversible operation, so it's essential to be certain that it won't cause any problems with your shop's payment processing capabilities. If you're unsure whether it's safe to delete a payment provider, it's always a good idea to consult with a technical expert or support team member who can provide guidance and advice based on your specific setup and needs. --- ## Products Products are the integral part of any e-commerce shop and Unchained engine provides multiple product types to suit your needs. On the unchained Admin Ui you can mange all your shop products with an intuitive interface and perform tasks suc as: - Add new product - Edit product information and Add localized variation of a product information or delete exiting product - Add media's to a product - Price a product - Add token information of a token product type (more detail on this can be found below) - Create a bundle product - Add product variation - Add subscription plan configuration to a product - Activate deactivate - View and/or search and/filter product ## Overview Before we talk about all the product configuration capabilities available in unchained lets first have an overview of the different type of products supported in unchained. Unchained Engine is a powerful e-commerce platform that enables merchants to create and manage their online stores with ease. It provides a range of features and functionalities that help merchants to sell their products online, such as inventory management, order processing, payment processing, and more. Here are the various types of products that Unchained Engine supports: 1. **SimpleProduct**: A simple product is a standard product with a fixed price and no variations or customization options. This type of product is suitable for merchants who sell standard, one-size-fits-all products that don't require any additional options or configurations. Examples of simple products include a single book or a basic t-shirt. **Use case**: A clothing store might sell a basic t-shirt as a simple product. The product would have a fixed price and no variations or customization options. Customers would simply choose their size and color, add the item to their cart, and check out. 2. **TokenizedProduct**:A tokenized product is a digital product that is stored on a blockchain network and can be traded as a token. This type of product is useful for merchants who want to sell digital assets [NFT](https://ethereum.org/en/nft/) such as digital art or music in a secure and decentralized way. **Use case**: An online marketplace for digital art might sell tokenized products that represent ownership of a specific piece of art. Customers would purchase the token and receive ownership of the artwork on the blockchain network. The artwork could then be traded or sold by the customer on the blockchain network. **Use case**: An online marketplace for digital art might sell tokenized products that represent ownership of a specific piece of art. Customers would purchase the token and receive ownership of the artwork on the blockchain network. The artwork could then be traded or sold by the customer on the blockchain network. 3. **PlanProduct**: A plan product is a subscription-based product that customers pay for on a recurring basis. This type of product is useful for merchants who offer ongoing services or products that require regular replenishment. Examples of plan products include a monthly subscription to a meal delivery service or a quarterly subscription to a magazine. **Use case**: A meal delivery service might offer a plan product that provides customers with a certain number of meals each week. Customers would sign up for the plan and receive their meals on a recurring basis. The merchant would automatically bill the customer for the plan each month until they cancel. 4. **BundleProduct**: A bundle product is a collection of simple products that are sold as a package. This type of product is useful for merchants who want to sell related products together or provide customers with a discount for purchasing multiple products at once. Examples of bundle products include a set of kitchen utensils or a collection of books. **Use case**: A bookstore might sell a bundle product that includes multiple books in a specific genre or by a specific author. The bundle would have a discounted price compared to buying each book separately. Customers would add the bundle to their cart and receive all the books in the bundle when they check out. 5. **ConfigurableProduct**: A configurable product is a product that has multiple options or configurations. This type of product is useful for merchants who sell products with variations or customization options, such as clothing with different sizes and colors or laptops with different specifications. **Use case**: A clothing store might sell a configurable product that allows customers to choose the size, color, and style of a shirt. The price of the shirt would vary based on the selected options. Customers would choose their options, see the updated price, and add the item to their cart. ## View and filter and/or search products Product list view page provides a comprehensive list of all the products available on your e-commerce site. You can efficiently manage your product catalog by using various filter options like tags and status, or searching for a specific product. By utilizing these filters, you can narrow down your search and streamline your product management process. ![diagram](../assets/products-list.png) ## Add product To add a new product to your e-commerce store, navigate to the product list view and click on the "Add" button. This will open a form where you can provide the necessary details for the new product. The form will include fields for the product title and type, where the type should be selected from the list of supported product types for your store. ![diagram](../assets/product-form.png) ## Edit Product Once you have opened the detail view of a product, you can update various information about the product depending on its type. Here are some of the common actions that you can perform: - **Edit product information**: You can update the product title, description, and other general information about the product. - **Add localized variations of product information**: If you are selling products in multiple regions or languages, you can add localized variations of the product information to make it easier for customers to understand the product. - **Add media to a product**: You can add images and videos to showcase the product and help customers visualize it better. - **Price a product**: You can set the price of the product, and configure discounts and promotions if required. - **Add token information of a token product type**: If you are selling tokenized products, you can add token-specific information such as the token address, token ID, and other relevant details. - **Create a bundle product**: If you are selling a bundle of products, you can add or remove products from the bundle, and adjust the pricing accordingly. - **Add product variations**: If you are selling configurable products, you can add variations such as size, color, and style, and set different prices for each variation. - **Add subscription plan configuration to a product**: If you are selling plan products, you can configure the subscription plan details such as billing cycle, start and end dates, and payment method. - **Activate or deactivate a product**: You can activate or deactivate a product depending on its availability. ### Global update options Below are updatable options found on every product type 1. **texts**: The "texts" tab on the product detail view allows you to update all the textual information of a product such as its title, subtitle, and description. You can also add localized text for all the supported languages in your shop by selecting the language at the top right of the form. This ensures that customers from different regions can view the product information in their preferred language. It is important to note that before adding a localized text, you need to ensure that the language is added to your shop's supported languages. You can add a new language by using the [Add language](./language/#add-language) form available in the [Language](./language) section. Once the language is added, you can then add the localized text for the specific language in the Texts tab of the product detail view. ![diagram](../assets/product-text-setting.png) 2. **Media**: The "media" tab on the product detail page allows you to manage the media files associated with a product. You can add, update, or delete media files to provide additional information about the product or to make the user interface more appealing. You can also add localized text in all the supported languages of your shop by selecting the language you want at the top right of the form. Please note that in order to add localized text, you must first [add language](./language/#add-language) using the new language form. ![diagram](../assets/product-media-setting.png) 3. **Tags**: Tags are useful for providing additional information about a product that distinguishes it from others. You can add or remove tags for a product by clicking on the "Tags" button found at the top of the product detail page. ![diagram](../assets/product-tag-settings.png) 4. **Sequence**: You can reorder the products on the product list page by adjusting their sequence. Products with a lower sequence number will appear first in the list. To change the sequence, toggling "Sort" value found at the top of the page. ![diagram](../assets/product-sequence-setting.png) 5. **Status**: The default behavior of products in the shop is to be active and displayed to customers. However, if you need to temporarily hide a product from customers while keeping it in the shop, you can change its status to draft. To do so, you simply toggle the button displaying the current status of the product and select "draft" as the new status. All operations on a product, such as updating or deleting, can be performed regardless of its status, but only active products are returned by default. ![diagram](../assets/publish-draft-product.png) 6. **Delete**: You can remove a product by clicking on the delete button available on a product detail page. A product is deletable only when it is in draft status. If a product is active then it cannot be deleted. In this case, you can change the product status to draft if you don't want to display it to customers but still want to keep it in your shop.as **DRAFT** state, so if you want to delete a product that is active change its status to do so. **Note: When deleting a product, it is important to ensure that the deletion does not cause any integrity issues or affect the shop's operations, such as active orders. It is essential to be cautious as this operation is not reversible.** ### Scoped update options Based on the product type there are additional configuration options available for a product. Below are all the available options for each product type 1. **Price or Commerce** **Applicable to products with type** - Simple - Tokenized - Bundle - Plan /subscription Since its a shop every product has a price and you can add one or multiple prices to a given product using the commerce tab available on the product detail page. on the product commerce form you will be required to provide the following information - **Max Quantity**:- refers to the maximum number of product that should be in an order for the price to be used for a product. if left empty or 0 then it will be used as the default price for a product unless there is another price entry with max quantity set. On that case that price will be used the number of products in a order satisfy it. - **Price**:- Actual price of the product. Note decimal pricing is not supported to you should enter price for a product by multiplying it with 100. for example if a product price is $35 then entry on the price field should be 3500 and if the price is $3.5 it should be entered as 350. - **Vat suspect**:- determines if tax should be added on the product price when calculating total price of a product. the applied tax can be different based on the ProductPricing plugin in configured on the engine. - **Net price**:- Determines wether the price is final or tax, discount, delivery and other additional costs should be added to it when calculating total price - **Country**:- if you have different price for a product based on the customers order location you can select the country where a given price is applicable using this field. In order to add a select a country for a price you need to add the country in question using the [new country form](./country/#add-country) first. - **Currency**:- Currency of the price. you can have multiple currency prices configured for a product and based on the order currency the corresponding price will be applied. In order to add a select a currency for a price you need to add the currency using the [new currency form](./currency/#add-new-currency) first. **Requirements of pricing** - There must be one price entry with max quantity set to 0 that can be used as the default price. - It is not possible to add multiple product price with the same max quantity, country and currency and it will create a conflict. ![diagram](../assets/product-price-setting.png) ### Specialized configuration options Below are configuration options available only for a specific product type. 1. **Supply** Available on **SimpleProduct** For a Simple product, you may need to provide additional information that outlines the dimensions of the product. This information can be added by navigating to the "Supply" tab found on the product detail page. On the product Supply form, you will be required to provide the following information: - **Weight**: Weight of the product. - **Length**: Length of the product. - **Width**: Width of the product. - **Height**: Height of the product. ![diagram](../assets/product-supply-setting.png) 2. **Warehousing** Available on **SimpleProduct** Physical or Simple products are typically stored in a warehouse and you may need to provide additional information about the product's storage. You can add this information by navigating to the "warehousing" tab on a product detail page. Here you can add the following storage-related information: - **SKU code**: A unique identifier assigned to a product for tracking purposes. - **Base unit of measure**: The unit of measure used for tracking the quantity of a product in the warehouse. For example, if a product is sold by weight, the base unit of measure could be grams or kilograms. ![diagram](../assets/product-warehousing-setting.png) 3. **Bundles** Available on **BundleProduct** Bundle products are a collection of products that are sold together as a single item. You can manage the bundle products by navigating to the ""Bundles" tab on the product detail page. On the Bundle configuration page, you can specify the products that make up the bundle by selecting them from a list of available products. You can also set a custom name and description for the bundle. Additionally, you can set a custom price for the bundle, which will override the prices of the individual products. You can also choose whether the bundle price is fixed or calculated based on the sum of the prices of the individual products. If a product that is part of a bundle is removed or becomes inactive, the bundle will be automatically updated to reflect the change. On the "Bundles" tab of a Bundle product, you can add or remove one or multiple products to be included in the bundle along with the quantity assigned to each product using the provided form. ![diagram](../assets/product-bundle-setting.png) 4. **Variation** Available on **ConfigurableProduct** A configurable product is a grouping of similar products with different characteristics, and you can add all the different variations of a product you want to sell in your shop using the "Variations" tab found on the product detail page. ![diagram](../assets/product-variation-create-setting.png) - **Adding variation option** Variation is the highest level of product differentiation, such as brand or type, for a configurable product. For instance, if you're selling laptops on your shop and there are various brands available, the brand name, such as HP, can be the top-level variation. However, to add specific differentiation factors to a configurable product, you'll need to create variation options using the form available under the variations tab on the product detail page. For example, suppose you have different HP laptop models with varying RAM sizes. In that case, you'll create a variation option for each RAM size, allowing customers to select the exact model they want to purchase. ![diagram](../assets/product-variation-setting.png) 5. **Assignments** Available on **ConfigurableProduct** After creating variation and variation options of a configurable product, the next step is to assign the actual product that satisfies the variation configuration. To manage the assignments of a product for each variation option, you can use the "assignments" tab found on the product detail page. In the assignments tab, you will see a list of all the variation options you have created for the configurable product. You can then assign the specific product that matches that variation option. For example, if you have a variation option for RAM size and you have products with different RAM sizes, you can assign the product with 4GB RAM to the "4GB" variation option. To assign a product to a variation option, simply select the variation option from the list and then select the product that matches that variation option. Once you have assigned all the products to their respective variation options, the configurable product will be ready for sale on your shop. ![diagram](../assets/product-variation-assignment.png) 6. **Token** Available on **TokenizedProduct** To provide additional information that defines a Tokenized product, which is a digital asset, there are specific details that need to be added. These include: - **Contract Standard**: This refers to the token standard that was used to create the digital asset. - **Contract Address**: This is the address of the token on the blockchain where it can be found. - **Supply**: This indicates the total number of tokens in circulation. - **Token Serial Number**: This is the unique identifier of the token. **Note that some fields might be optional depending on the contract standard used for the token implementation.** ![diagram](../assets/token-setting.png) --- ## Users Admin UI provides a comprehensive interface for managing customer information on an e-commerce site, allowing you to view and/or manage user data based on your access rights. By default, every logged-in user can manage their own account, but to view and manage other users' data, a special role such as an admin or custom-configured role is required. There are two types of users in Admin UI: guests and registered users. A guest user is created when the `loginAsGuest` API is used to log in. This is useful when you want to enable unregistered customers in your shop to still be able to add items to their cart and/or bookmark products without registering. However, when a user decides to checkout and order, they will need to register to complete the process. If you have the appropriate privileges to view and/or manage other users' information, including guest users, you can do so by navigating to the "Users" page, where you can: - View all users in the shop with filter and search capabilities - Add a new user - View detailed user information such as profile info, orders, quotations, subscriptions, and payment credentials - Edit user profile information - Delete a user - View all orders made by a user - View all quotations made by a user - View all subscriptions made by a user - View all payment credentials of a user ## View all Users When you navigate to the Users page, you will be presented with an intuitive user interface that allows you to perform several actions such as: - Filter and search through the list of users in the shop based on different criteria such as name, email, date of registration, etc. This makes it easier to find specific users quickly. ![diagram](../assets/users-list.png) ## Add new user To add a new user to your shop, navigate to the Users page and click on the "Add" button located on the list view. You must have the necessary access rights to perform this action. The Add button will take you to a form where you can fill in the necessary details for the new user. Once the user has been added successfully, you will be redirected to the newly created user's detail page. On this page, you can view and edit additional information such as the user's profile picture, additional email addresses, and more. ![diagram](../assets/new-user-form.png) ## View and/or edit user detail To provide a more comprehensive documentation, here are the details that you can view on the user detail page when you open a user from the list: - Profile Information: You can view the user's name, email address, phone number, and other details that were provided during registration. - Orders: You can view all the orders that were placed by the user, including the order date, status, total amount, and payment information. - Quotations: You can view all the quotations that were sent to the user, including the quotation date, status, and total amount. - Subscriptions: You can view all the subscriptions that were created by the user, including the subscription date, status, and payment information. - Payment Credentials: You can view all the payment credentials that were added by the user, including the payment method and the last four digits of the card number. Additionally, if you have the access right to edit user information, you can also make changes to the user's profile, add or remove payment credentials, and manage the user's subscriptions. 1. ### Profile information of a user On the Profile tab, you can view personal information such as the user's name, address, birthday, and other related details. If you have the necessary permissions, you can also edit this information by clicking on the edit button provided. ![diagram](../assets/user-profile-setting.png) 2. ### Account information of a user The Account tab on the user detail page displays information such as email addresses, web3 addresses, and tags associated with the user's account. If you have the appropriate access rights, you can edit these details. The following information is displayed on this tab: - **Email addresses**: This is an editable list of email addresses linked to the user's account. - **Web3 addresses**: This is a list of blockchain addresses associated with the user's account. This list is also editable. - **Tags**: This is an editable list of tags associated with the user's account. ![diagram](../assets/manage-web3-addresses.png) - **Roles**:- The user's assigned roles in the shop. - **Password change**:- used to change the user's current password. By default, a user with an "Admin" role has the ability to change any user's password using the "Set Password" form, which is only visible to accounts with the appropriate permissions. ![diagram](../assets/set-password.png) - **Web Authn**:- The "WebAuthn" tab in the user detail page displays information about the devices used for [Web authn](https://webauthn.guide/) authentication method. [Web authn](https://webauthn.guide/) is a modern authentication method that allows users to authenticate themselves with their devices, such as smartphones or security keys. The tab provides information about the devices used for [Web authn](https://webauthn.guide/) authentication, such as their names, types, and statuses. ![diagram](../assets/user-account-setting-1.png) 3. ### Additional information of a user In addition to the above setting related information you can also view and search data of a user such as Order, Quotations, Subscriptions and Payment credentials using the tab. --- ## Warehousing provider You can manage the warehousing providers in your shop by navigating to the "Warehousing Providers" page in the admin UI. This page is typically found under the "System Settings" section of the UI, and allows you to view and filter the existing warehousing providers that are configured for your shop. The admin UI provides all the functionality you need to manage the configured warehousing plugins in your shop. This includes: - **View and Filter**: You can view and filter the existing warehousing providers in your shop by various criteria, such as provider name or status. - **Add New Warehousing Provider**: To add a new warehousing provider, simply click the "Add" button found on the warehousing provider list view. You'll be presented with a form where you can enter the provider type and adapter for your new provider. The adapter refers to the warehousing provider adapter plugin that is configured and loaded in your engine instance. - **Update Existing Warehousing Provider**: You can update an existing warehousing provider by clicking the "Edit" icon next to that provider in the warehousing provider list view. This will open up a form where you can update the provider's configuration details. - **Delete Warehousing Provider**: You can delete a warehousing provider from your shop by clicking the "Delete" button on either the provider list view or the provider detail page. However, be sure to check for any potential data integrity issues before deleting a provider, as the operation is not reversible. It's important to note that in order to add or manage a warehousing provider in the admin UI, the provider must first be configured and loaded into your shop's unchained engine instance. Additionally, activating or deactivating warehousing providers is typically controlled by the plugin logic, rather than through the admin UI. Note that in order to add or manage a warehousing provider in the admin UI, the provider must first be configured and loaded into your shop's unchained engine instance. This can typically be done through the installation and configuration of a warehousing provider adapter plugin. Additionally, it's important to note that activating or deactivating warehousing providers is typically controlled by the plugin logic, rather than through the admin UI. This means that simply adding a warehousing provider through the admin UI does not necessarily mean it will be active and ready to use. Be sure to check the provider's documentation for information on how to activate and configure it for use in your shop. ## View and Filter warehousing providers To view and filter the warehousing providers that have been added to your shop, you can navigate to the "Warehousing Providers" page in the admin UI. From there, you can use various filtering and search options to find the providers you're looking for, including filtering by provider type. ![diagram](../assets/warehousing-provider-list.png) ## Add new warehousing providers When you navigate to the "Warehousing Providers" page in the admin UI, you can add a new provider by clicking the "Add" button on the list view. This will take you to a form where you can enter the necessary information for the new provider, including the type and adapter for your warehousing provider. The adapter refers to the warehousingProvider adapter plugin that is already configured and loaded in your engine instance. After submitting the form, you will be redirected to the detail page for the newly created warehousing provider. From there, you can view and manage its settings as needed. ![diagram](../assets/new-warehousing-provider.png) ## Update warehousing providers To edit an existing warehousing provider in Unchained, follow these steps: - Navigate to the "Warehousing Providers" page in your shop's admin UI. This page can be accessed via the "System Settings" menu. - Find the warehousing provider you wish to edit in the list view. You can filter the providers by various criteria to make it easier to find the one you're looking for. - Click the edit icon for the provider you wish to edit. This will take you to the detail view for that provider. - From here, you can update various aspects of the warehousing provider, including the configuration. Simply make the necessary changes and click the "Save" button to apply them. - If there are any configuration errors present, you will see a red "x" icon next to the relevant section. Hovering over this icon will display any helpful error text that may be available. It's important to note that any changes you make to a warehousing provider may impact the overall functionality of your shop, so be sure to test thoroughly before deploying any updates to a live environment. ![diagram](../assets/edit-warehousing-provider.png) ## Delete warehousing providers o delete a warehousing provider in Unchained, you can follow these steps: - Navigate to the Warehousing Providers page by going to System Settings > Warehousing Providers. - Locate the warehousing provider that you want to delete in the list view. You can use the filter and search functionalities to find it quickly. - Click the "Delete" button located in the same row as the warehousing provider you want to delete. - A confirmation dialog will appear asking you to confirm the deletion. Review the information carefully to ensure that you are deleting the correct warehousing provider. If you are sure, click the "Confirm" button. - If the warehousing provider can be deleted, it will be removed from the system and you will see a success message. If there are any dependencies or relationships that prevent the deletion, you will see an error message indicating what needs to be resolved before the warehousing provider can be deleted. Note that deleting a warehousing provider should be done with caution as it may cause integrity issues if there are any dependencies or relationships that rely on it. Therefore, it's recommended to review the dependencies before proceeding with the deletion. --- ## Work Queue To manage workers in Unchained, you can navigate to the worker page in the system settings section of the admin UI. This page allows you to manage all the workers that are currently active in the Unchained engine. Using the worker page, you can perform the following actions: - **View and filter workers**: The page displays all the workers that are currently active in the Unchained engine. You can also filter the workers by various criteria, such as worker name or status. - **Add work to the queue**: You can add new work to the worker queue by specifying the work type, priority, and other relevant information. - **Allocate work to specific workers**: You can allocate specific work to specific workers, allowing you to prioritize certain tasks or ensure that certain workers are handling specific types of work. - **Delete work from the queue**: If work is no longer needed, you can delete it from the worker queue. By using the worker page in the admin UI, you can easily manage the workers that are responsible for executing tasks related to the shop's operations. This allows you to monitor the status of tasks and allocate resources as needed to ensure that tasks are completed in a timely and efficient manner. Please note that workers are typically controlled by the plugin logic itself. This means that adding or deleting work in the admin UI does not necessarily guarantee that it will be executed by the workers. You will need to make sure that the plugin code and configurations are properly set up and maintained to ensure the smooth functioning of your workers. ## View work queue To manage the workers in your Unchained engine, you can navigate to the activities > work queue page in the admin UI. This page displays all active and completed workers in your system and provides several filtering options to help you find the specific information you need. By using the work queue page, you can perform the following tasks: - **View and filter work queue**: The page displays all the workers that are currently active or completed in your Unchained engine. You can use various filters to narrow down the list, such as work start and end date, worker type, and worker status. - **Add work to the queue**: You can add new work to the queue by clicking on the "Add Work" button on the work queue page. This opens a form where you can enter the required information for the work, such as the worker type, payload, and priority. - **Allocate work to specific workers**: You can allocate specific work to a particular worker by using the "Assign" button on the work queue page. This allows you to distribute workloads across different workers based on their capabilities and availability. - **Delete work from the queue**: If work is no longer needed or has been completed, you can delete it from the work queue by clicking on the "Delete" button next to the work item. This removes the work from the queue and frees up resources for other tasks. By using the work queue page in the admin UI, you can easily manage the workers in your Unchained engine and ensure that all tasks are being executed efficiently and effectively. The filtering and allocation options available on this page make it easy to keep track of your workers' performance and adjust your workload as needed. It is important to note that the workers in your Unchained engine are responsible for executing critical tasks related to your e-commerce shop's operations, such as order processing and inventory management. Therefore, it is crucial to monitor the work queue regularly to ensure that all tasks are being completed successfully and in a timely manner. ![diagram](../assets/work-queue-list.png) ## Work detail To manage the worker module in Unchained engine, you can navigate to the "activities" section in the admin UI and select "work queue". Here, you can view all the active and completed workers in the engine. The work queue page provides search and filter functionalities that allow you to easily locate specific workers. You can search for workers based on their start and end date, worker type, or worker status. Once you locate a specific worker, you can click on its list item to explore more details about the worker. The worker detail page provides you with information such as its status, start and end time, duration, input, result, and any error that occurred during execution. This information can be useful for debugging and troubleshooting issues related to worker operations in your e-commerce shop. By having access to the worker module in the Unchained engine, you can manage various tasks related to the shop's operations such as order processing, inventory management, and shipping. The ability to monitor and manage the workers in your e-commerce shop ensures that your business runs smoothly and efficiently. ![diagram](../assets/work-detail.png) ## Add work To add new work to the work queue in Unchained engine, you can navigate to the work list page and click on the "Manage" button. However, before you can add new work, you need to ensure that the worker adapter is configured and activated in the engine. Once you have access to the "Add Work" page, you will be prompted to provide information such as the Worker type and any additional information required by the worker module. Some of the optional fields available when adding work include: - `Priority`: This field allows you to assign a priority value to the worker, with values closer to 0 being given higher priority over those with larger priority values. - `Retries`: This field specifies the number of times the worker should be retried in case of a failure. - `Original Work ID`: If you want to use a previously run worker, you can provide its ID in this field. - `Scheduled`: This field allows you to specify a time when the worker should be run. If left undefined, the worker will be run as soon as possible. - `Input`: This field allows you to pass data to the worker based on its configuration. Once you have provided all the necessary information, you can click on the "Add Work" button to add the new worker to the work queue. The worker will be assigned a status of "New" automatically. By using the "Add Work" feature, you can easily add new tasks to the work queue and ensure that they are processed by the Unchained engine as needed. This allows you to manage your e-commerce shop more effectively and ensure that all operations are carried out smoothly and efficiently. ![diagram](../assets/add-work-form.png) ## Allocate work To manually start a worker with a status of "NEW" in the work queue, you can use the "Allocate Work" button found in the work list page. This action will allocate the worker to an available worker adapter for processing. When allocating work, you have the option to select a specific worker adapter to handle the task or let the engine choose the adapter automatically. You can also specify the number of workers to allocate to the task. Once the worker has been allocated, its status will change to "Allocated". The worker will then be processed by the worker adapter until it is completed, at which point its status will be updated to "Complete". If an error occurs during processing, the worker's status will be changed to "Error" and you can view the details of the error in the worker details page. It is important to note that not all workers can be manually allocated. Some workers may require specific conditions to be met before they can be processed, such as dependencies on other workers or certain data being available. In these cases, the engine will automatically allocate the worker when the necessary conditions are met. ![diagram](../assets/allocate-work-form.png) ## Delete work Only work items with a status of "NEW" can be deleted in Unchained. To delete a work item, you can open its detail page by clicking on its entry in the work queue list. From there, you can click on the "Delete" button to remove the work item from the queue. It's important to note that once a work item has been deleted, it cannot be recovered and any associated data or results will also be lost. Therefore, it's recommended to only delete work items that are no longer needed and have not yet been started by the worker module. ![diagram](../assets/delete-work.png) --- ## Admin Copilot Setup # Admin Copilot The Admin UI includes a built-in AI Copilot that can manage products, orders, users, and more through natural language. It connects to the [MCP server](./mcp-server) internally, so all MCP tools are available to the Copilot. ## How it works The Copilot exposes two HTTP endpoints: | Endpoint | Method | Purpose | |----------|--------|---------| | `/chat` | POST | Stream AI responses with tool execution | | `/chat/tools` | GET | List available tools and their schemas | The Chat API uses the [Vercel AI SDK](https://ai-sdk.dev/) to stream responses. It connects to the MCP server internally to execute tools on behalf of the user. ## Setup To enable the Copilot, pass a `chat` configuration when connecting your server framework. You need to install a model provider from the [AI SDK providers list](https://ai-sdk.dev/providers/ai-sdk-providers). ### Self-hosted LLM (OpenAI-compatible) If you have a machine with at least 24 GB of VRAM, you can run an LLM locally. Any service exposing an OpenAI-compatible API will work: ```bash llama-server -hf ggml-org/gpt-oss-20b-GGUF --ctx-size 0 --jinja -ub 2048 -b 2048 export OPENAI_COMPAT_API_URL=http://127.0.0.1:8080/v1 export OPENAI_COMPAT_MODEL=gpt-oss ``` ```typescript // ... after creating fastify instance and unchained platform ... if (process.env.OPENAI_COMPAT_API_URL && process.env.OPENAI_COMPAT_MODEL) { const provider = createOpenAICompatible({ name: 'local', baseURL: process.env.OPENAI_COMPAT_API_URL, }); connect(fastify, engine, { chat: { model: provider.chatModel(process.env.OPENAI_COMPAT_MODEL), }, }); } ``` ### OpenAI ```typescript // ... after creating fastify instance and unchained platform ... if (process.env.OPENAI_API_KEY) { connect(fastify, engine, { chat: { model: openai('gpt-4-turbo'), imageGenerationTool: { model: openai.image('gpt-image-1') }, }, }); } ``` With `imageGenerationTool` enabled, the Copilot can generate product and category images. ### Anthropic ```typescript if (process.env.ANTHROPIC_API_KEY) { connect(fastify, engine, { chat: { model: anthropic('claude-sonnet-4-20250514'), }, }); } ``` ## Configuration options The `chat` object extends the Vercel AI SDK's `streamText` parameters (minus `messages`), so all [streamText options](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) are supported. The most commonly used options are: | Option | Type | Required | Description | |--------|------|----------|-------------| | `model` | `LanguageModelV1` | Yes | AI model instance from any Vercel AI SDK provider | | `system` | `string` | No | Custom system prompt (a sensible default is built in) | | `tools` | `Record` | No | Additional custom tools beyond MCP tools | | `temperature` | `number` | No | Model temperature (default: `0.2`) | | `maxRetries` | `number` | No | Retry count on failure (default: `3`) | | `unchainedMCPUrl` | `string` | No | MCP server URL, defaults to `${ROOT_URL}/mcp` | | `imageGenerationTool` | `{ model, uploadUrl? }` | No | Enable image generation in the Copilot | ### Image generation When `imageGenerationTool` is configured, a `generateImage` tool becomes available in the Copilot. It supports these sizes: `512x512`, `768x768`, `1024x1024`, `512x896`, `640x1120`, `768x1344`, `1024x1792`, `896x512`, `1120x640`, `1344x768`, `1792x1024` Generated images are automatically uploaded to the engine's temporary upload endpoint and can be attached to products or categories. ### Custom tools You can extend the Copilot with additional tools: ```typescript connect(fastify, engine, { chat: { model: openai('gpt-4-turbo'), tools: { checkInventory: tool({ description: 'Check real-time inventory for a SKU', parameters: z.object({ sku: z.string() }), execute: async ({ sku }) => { // Your inventory check logic return { sku, available: 42 }; }, }), }, }, }); ``` ## Chat API details ### POST /chat Streams AI responses with automatic tool execution. **Request body:** ```json { "messages": [ { "role": "user", "content": "List the top 5 products by revenue" } ] } ``` **Response:** Server-sent events stream (Vercel AI SDK UI message format). The chat handler: - Keeps the last 10 messages for context - Executes up to 500 tool calls per request (Fastify) or 10 (Express) - Uses temperature `0.2` for deterministic responses - Injects shop configuration (languages, currencies, countries) into the system prompt automatically ### GET /chat/tools Returns all available tools grouped by category: ```json { "tools": [ { "name": "product_management", "description": "...", "parameters": { ... }, "category": "Product Management" } ], "cached": false } ``` --- ## AI Integration FAQ This FAQ covers Unchained Engine's AI capabilities. It is written for both human developers and AI agents consuming this documentation. ## General ### What AI features does Unchained Engine provide? Three integration surfaces: 1. **MCP Server** β€” A Model Context Protocol server at `/mcp` that exposes the full commerce API as AI-callable tools. Compatible with Claude Desktop, Claude Code, Cursor, and any MCP client. 2. **Admin Copilot** β€” A chat interface in the Admin UI powered by the Vercel AI SDK. Connects to the MCP server internally. 3. **llms.txt** β€” Static files (`/llms.txt` and `/llms-full.txt`) that help LLMs discover and navigate the documentation. ### Which AI models are supported? - **Admin Copilot**: Any model supported by the [Vercel AI SDK](https://ai-sdk.dev/providers/ai-sdk-providers) β€” OpenAI, Anthropic, Google, Mistral, local models via OpenAI-compatible APIs, and more. - **MCP Server**: Model-agnostic. Any MCP-compatible client can connect regardless of the underlying model. ### Can I use a local/self-hosted LLM? Yes. Any service that exposes an OpenAI-compatible API endpoint works with the Admin Copilot. See the [self-hosted LLM setup](./admin-copilot#self-hosted-llm-openai-compatible). ## MCP Server ### What is the MCP endpoint URL? `/mcp` on your engine's root URL. For example, if your engine runs at `https://engine.example.com`, the MCP endpoint is `https://engine.example.com/mcp`. The path is configurable via the `MCP_API_PATH` environment variable. ### What authentication does the MCP server require? An authenticated user with the `admin` role. Pass the session token via `Authorization: Bearer ` header or cookies. Unauthenticated requests return `401`. ### What transport protocol does the MCP server use? Streamable HTTP β€” the standard MCP HTTP transport. It supports `POST` (send messages), `GET` (query sessions), and `DELETE` (clean up sessions) on the `/mcp` endpoint. ### How many tools are available? The MCP server provides 9 tool categories: Product Management, Order Management, Assortment Management, User Management, Filter Management, System Management, Localization Management, Provider Management, and Quotation Management. Each category contains multiple operations. See the [MCP Server Reference](./mcp-server) for the full list. ### What resources does the MCP server expose? Three read-only resources for shop configuration: - `unchained://shop/languages` β€” Active languages - `unchained://shop/currencies` β€” Active currencies with decimal precision - `unchained://shop/countries` β€” Active countries ### How are prices represented? All prices are **integers**. The decimal precision depends on the currency. For example, CHF has 2 decimal places, so a price of `1990` means `19.90 CHF`. Always check the currencies resource for the correct number of decimals before interpreting or setting prices. ### Should I validate localization entities before using them? Yes. Always read the MCP resources (`languages`, `currencies`, `countries`) before creating or referencing localization entities. This avoids errors from referencing entities that don't exist in the shop configuration. ## Admin Copilot ### How do I enable the Admin Copilot? Pass a `chat` configuration with a model when connecting your server framework. See the [Admin Copilot Setup](./admin-copilot) for examples with OpenAI, Anthropic, and self-hosted models. ### Can the Copilot generate images? Yes, if you configure `imageGenerationTool` in the chat options. The Copilot can then generate product and category images on request. ### Can I add custom tools to the Copilot? Yes. Pass additional tools via the `tools` option in the chat configuration. These are added alongside the MCP tools. See [Custom tools](./admin-copilot#custom-tools). ### Can I customize the system prompt? Yes. Pass a `system` string in the chat configuration to override the default system prompt. The default prompt instructs the AI to validate resources before tool calls, handle prices as integers, and execute tools silently. ## Integration Patterns ### How do I connect Claude Desktop to Unchained? Add an entry to your `claude_desktop_config.json`: ```json { "mcpServers": { "unchained": { "url": "https://your-engine.example.com/mcp", "headers": { "Authorization": "Bearer YOUR_ADMIN_TOKEN" } } } } ``` See [Connecting AI clients](./mcp-server#connecting-ai-clients) for Claude Code and Cursor examples. ### Can I build a custom AI agent that manages my store? Yes. Connect to the MCP server using the `@modelcontextprotocol/sdk` package. See the [Custom agents example](./mcp-server#custom-agents-typescript). ### Can I use the MCP server and GraphQL API together? Yes. The MCP server operates on the same core modules as the GraphQL API. Changes made through MCP tools are immediately visible via GraphQL and vice versa. ## Troubleshooting ### I get a 401 error when connecting to the MCP server Verify that: 1. Your token is valid and not expired 2. The user associated with the token has the `admin` role 3. The `Authorization: Bearer ` header is set correctly ### The Copilot returns "NoSuchToolError" This means the AI tried to call a tool that doesn't exist. This can happen with models that hallucinate tool names. Try a more capable model or adjust your system prompt. ### The Copilot returns "LimitExceeded" You've hit the rate limit of your AI model provider. Wait and retry, or switch to a provider with higher rate limits. ### Prices look wrong (e.g., 1990 instead of 19.90) Prices are stored as integers. Divide by 10^(decimal places) for the currency. Check the currencies resource for the decimal precision of each currency. ### The MCP session disconnects Sessions are stored in memory. If the engine restarts, all sessions are lost. MCP clients should handle reconnection gracefully. --- ## AI Integration Unchained Engine provides first-class AI integration through three complementary surfaces: | Surface | Who uses it | Protocol | Auth | Endpoint | |---------|-------------|----------|------|----------| | [MCP Server](./mcp-server) | AI agents & IDEs (Claude Desktop, Cursor, Claude Code) | Streamable HTTP (MCP) | Admin bearer token | `/mcp` | | [Admin Copilot](./admin-copilot) | Store operators via Admin UI chat | HTTP streaming (Vercel AI SDK) | Session cookie | `/chat` | | `llms.txt` | LLMs & web crawlers | Static files | None | `/llms.txt`, `/llms-full.txt` | ## Architecture ```mermaid graph TB subgraph "AI Clients" CD[Claude Desktop] CC[Claude Code] CU[Cursor] AU[Admin UI Copilot] CA[Custom Agents] end subgraph "Unchained Engine" GQL[GraphQL API] MCP[MCP Server/mcp] CHAT[Chat API/chat] CORE[Core Modules] DB[(MongoDB)] end CD -->|MCP Protocol| MCP CC -->|MCP Protocol| MCP CU -->|MCP Protocol| MCP AU -->|HTTP Stream| CHAT CA -->|MCP Protocol| MCP CHAT -->|Internal MCP Client| MCP MCP --> CORE GQL --> CORE CORE --> DB ``` The MCP server exposes the full commerce API as AI-callable tools. The Chat API (used by the Admin UI Copilot) connects to the MCP server internally and streams responses back to the browser. ## Quick links - **[MCP Server Reference](./mcp-server)** β€” Tool categories, resources, authentication, and connection examples for AI agents - **[Admin Copilot Setup](./admin-copilot)** β€” Configure the built-in chat assistant in the Admin UI - **[AI FAQ](./ai-faq)** β€” Common questions about AI capabilities, answered for both humans and AI agents ## llms.txt This documentation site publishes [`/llms.txt`](https://docs.unchained.shop/llms.txt) and [`/llms-full.txt`](https://docs.unchained.shop/llms-full.txt) following the [llms.txt standard](https://llmstxt.org/). These files help AI models discover and navigate the documentation efficiently. --- ## MCP Server Reference # MCP Server Unchained Engine includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes the full commerce API as AI-callable tools. Any MCP-compatible client β€” Claude Desktop, Claude Code, Cursor, or custom agents β€” can connect and manage your store programmatically. The MCP server is always available at the `/mcp` endpoint (configurable via `MCP_API_PATH` environment variable). No additional setup is needed beyond running Unchained Engine. ## Authentication - **Admin-only**: The MCP server requires an authenticated user with the `admin` role. - **Bearer token**: Pass your session token via `Authorization: Bearer ` header or through cookies. - **401 behavior**: Unauthenticated requests receive a `401` response with OAuth resource metadata. ## Transport The server uses the **Streamable HTTP** transport (the standard MCP HTTP transport): - **POST** `/mcp` β€” Send client messages (tool calls, resource reads) - **GET** `/mcp` β€” Query existing sessions - **DELETE** `/mcp` β€” Clean up sessions Sessions are identified by the `mcp-session-id` header, generated on first connection. ## Tool categories The MCP server organizes its tools into 9 categories with granular operations: ### 1. Product Management Full product lifecycle including media, variations, bundles, and pricing. | Operation group | Actions | |----------------|---------| | CRUD | `CREATE`, `UPDATE`, `REMOVE`, `GET`, `LIST`, `COUNT` | | Status | `UPDATE_STATUS` (publish/unpublish) | | Media | `ADD_MEDIA`, `REMOVE_MEDIA`, `REORDER_MEDIA`, `GET_MEDIA`, `UPDATE_MEDIA_TEXTS` | | Variations | `CREATE_VARIATION`, `REMOVE_VARIATION`, `ADD_VARIATION_OPTION`, `REMOVE_VARIATION_OPTION`, `UPDATE_VARIATION_TEXTS` | | Assignments | `ADD_ASSIGNMENT`, `REMOVE_ASSIGNMENT`, `GET_ASSIGNMENTS`, `GET_VARIATION_PRODUCTS` | | Bundles | `ADD_BUNDLE_ITEM`, `REMOVE_BUNDLE_ITEM`, `GET_BUNDLE_ITEMS` | | Pricing | `SIMULATE_PRICE`, `SIMULATE_PRICE_RANGE`, `GET_CATALOG_PRICE` | | Text | `GET_PRODUCT_TEXTS`, `GET_MEDIA_TEXTS`, `GET_VARIATION_TEXTS` | | Reviews | `GET_REVIEWS`, `COUNT_REVIEWS` | | Other | `GET_SIBLINGS` | Supported product types: `SIMPLE`, `CONFIGURABLE`, `BUNDLE`, `PLAN`, `TOKENIZED`. ### 2. Order Management Read-only order listing and analytics. | Operation group | Actions | |----------------|---------| | Queries | `LIST` | | Analytics | `SALES_SUMMARY` (daily), `MONTHLY_BREAKDOWN` (12 months), `TOP_CUSTOMERS`, `TOP_PRODUCTS` | Supports date-range filtering and provider-based segmentation. ### 3. Assortment Management Category trees with products, filters, links, and media. | Operation group | Actions | |----------------|---------| | CRUD | `CREATE`, `UPDATE`, `REMOVE`, `GET`, `LIST`, `COUNT` | | Status | `UPDATE_STATUS` (activate/deactivate) | | Media | `ADD_MEDIA`, `REMOVE_MEDIA`, `REORDER_MEDIA`, `GET_MEDIA`, `UPDATE_MEDIA_TEXTS` | | Products | `ADD_PRODUCT`, `REMOVE_PRODUCT`, `GET_PRODUCTS`, `REORDER_PRODUCTS` | | Filters | `ADD_FILTER`, `REMOVE_FILTER`, `GET_FILTERS`, `REORDER_FILTERS` | | Links | `ADD_LINK`, `REMOVE_LINK`, `GET_LINKS`, `REORDER_LINKS` | | Navigation | `GET_CHILDREN`, `SET_BASE` | | Search | `SEARCH_PRODUCTS` | ### 4. User Management Full user lifecycle, roles, emails, and related data. | Operation group | Actions | |----------------|---------| | CRUD | `LIST`, `GET`, `CREATE`, `UPDATE`, `REMOVE`, `COUNT` | | Enrollment | `ENROLL`, `SEND_ENROLLMENT_EMAIL`, `SEND_VERIFICATION_EMAIL` | | Admin | `SET_ROLES`, `SET_TAGS`, `SET_PASSWORD`, `SET_USERNAME` | | Email | `ADD_EMAIL`, `REMOVE_EMAIL` | | Data access | `GET_ORDERS`, `GET_ENROLLMENTS`, `GET_QUOTATIONS`, `GET_BOOKMARKS`, `GET_CART`, `GET_PAYMENT_CREDENTIALS`, `GET_AVATAR`, `GET_REVIEWS`, `GET_REVIEWS_COUNT` | | Current user | `GET_CURRENT_USER` | ### 5. Filter Management Search filters with options and localized texts. | Operation group | Actions | |----------------|---------| | CRUD | `CREATE`, `UPDATE`, `REMOVE`, `GET`, `LIST`, `COUNT` | | Options | `CREATE_OPTION`, `REMOVE_OPTION` | | Text | `UPDATE_TEXTS`, `GET_TEXTS` | ### 6. System Management Shop info, background workers, and event logs. | Operation group | Actions | |----------------|---------| | Shop | `SHOP_INFO` | | Workers | `WORKER_ADD`, `WORKER_REMOVE`, `WORKER_GET`, `WORKER_LIST`, `WORKER_COUNT`, `WORKER_ALLOCATE`, `WORKER_FINISH_WORK`, `WORKER_PROCESS_NEXT`, `WORKER_STATISTICS`, `WORKER_ACTIVE_WORK_TYPES` | | Events | `EVENT_GET`, `EVENT_LIST`, `EVENT_COUNT`, `EVENT_STATISTICS` | ### 7. Localization Management Countries, currencies, and languages. | Operation group | Actions | |----------------|---------| | All entities | `CREATE`, `UPDATE`, `REMOVE` | Countries use 2-letter ISO codes, currencies use 3-letter ISO codes, languages use BCP 47 locale codes. ### 8. Provider Management Payment, delivery, and warehousing providers. | Operation group | Actions | |----------------|---------| | CRUD | `CREATE`, `UPDATE`, `REMOVE`, `GET`, `LIST` | | Discovery | `INTERFACES` (list available adapter types) | ### 9. Quotation Management Request-for-quote lifecycle. | Operation group | Actions | |----------------|---------| | Queries | `LIST`, `GET`, `COUNT` | | Lifecycle | `REQUEST`, `VERIFY`, `MAKE_PROPOSAL`, `REJECT` | ## Resources The MCP server exposes 3 read-only resources that provide shop configuration: | Resource URI | Description | |-------------|-------------| | `unchained://shop/languages` | Active languages with ISO codes and BCP 47 format | | `unchained://shop/currencies` | Active currencies with ISO codes and decimal precision | | `unchained://shop/countries` | Active countries with ISO codes | AI agents should check these resources **before** using localization tools to validate that an entity exists. ## Important notes - **Prices are integers**: All monetary values are stored as integers. Check the currency resource for decimal precision (e.g., CHF has 2 decimals, so `1990` = `19.90 CHF`). - **Resource validation**: Always check resources before creating or referencing localization entities to avoid errors. - **Session management**: Sessions are stored in memory and cleaned up when closed. Long-running agents should handle reconnection. ## Connecting AI clients ### Claude Desktop Add to your `claude_desktop_config.json`: ```json { "mcpServers": { "unchained": { "url": "https://your-engine.example.com/mcp", "headers": { "Authorization": "Bearer YOUR_ADMIN_TOKEN" } } } } ``` ### Claude Code ```bash claude mcp add unchained \ --transport http \ --url https://your-engine.example.com/mcp \ --header "Authorization: Bearer YOUR_ADMIN_TOKEN" ``` ### Cursor Add to your `.cursor/mcp.json`: ```json { "mcpServers": { "unchained": { "url": "https://your-engine.example.com/mcp", "headers": { "Authorization": "Bearer YOUR_ADMIN_TOKEN" } } } } ``` ### Custom agents (TypeScript) ```typescript const transport = new StreamableHTTPClientTransport( new URL('https://your-engine.example.com/mcp'), { requestInit: { headers: { Authorization: 'Bearer YOUR_ADMIN_TOKEN', }, }, }, ); const client = new Client({ name: 'my-agent', version: '1.0.0' }); await client.connect(transport); // List available tools const { tools } = await client.listTools(); // Call a tool const result = await client.callTool({ name: 'product_management', arguments: { operation: 'LIST', limit: 10 }, }); ``` --- ## Architecture Unchained Engine is built using a layered architecture that separates concerns and enables customization at every level. ## Layered Approach | Layer | Description | |-------|-------------| | **App** | Your project-specific code | | **Platform** | Orchestration, GraphQL API, configuration | | **Service Gateway** | Cross-module workflows (checkout, pricing) | | **Core Modules** | Business logic and database abstractions | ```mermaid flowchart TD A["AppYour project code"] B["Platform@unchainedshop/platform"] C["Service GatewayCheckout, Pricing, Messaging"] D["Core Modules@unchainedshop/core-*"] E["InfrastructureMongoDB, Events, Logger"] A --> B --> C --> D --> E ``` ## App Layer The user-land app is where your project-specific code lives. Unchained Engine is loaded as a framework into a Node.js project. ```typescript // Your app boots the platform const engine = await startPlatform({ modules: { /* custom modules */ }, services: { /* custom services */ }, options: { /* configuration */ }, }); ``` ## Platform Layer The platform layer (`@unchainedshop/platform` and `@unchainedshop/api`) handles: - Loading all default core modules - Defining the GraphQL schema and resolvers - Starting the API server (Express or Fastify) - Managing the work queue for background jobs - Orchestrating module configuration - Email templates and messaging - Authentication and session management In rare cases, you might skip this layer to directly access core modules for: - Federated microservices - Custom non-GraphQL APIs - Batch processing scripts ## Service Gateway Layer The service gateway composes functions from multiple modules to enable complex workflows: | Service | Description | |---------|-------------| | `orders` | Checkout workflow, order pricing sheets | | `products` | Product pricing calculations | | `users` | User-related operations | | `files` | File operations with storage adapters | You can modify services by: 1. Using existing plugins 2. Writing custom plugins 3. Adding functions to `startPlatform` options ```typescript await startPlatform({ services: { orders: { // Custom order service methods }, }, }); ``` ## Core Modules Layer Core modules contain business logic and database abstractions. Each module is responsible for a specific domain: | Module | Package | Description | |--------|---------|-------------| | Products | `@unchainedshop/core-products` | Product catalog management | | Orders | `@unchainedshop/core-orders` | Order lifecycle and cart | | Users | `@unchainedshop/core-users` | User accounts and auth | | Payment | `@unchainedshop/core-payment` | Payment providers | | Delivery | `@unchainedshop/core-delivery` | Shipping providers | | Assortments | `@unchainedshop/core-assortments` | Categories and collections | | Filters | `@unchainedshop/core-filters` | Product search and filtering | | Warehousing | `@unchainedshop/core-warehousing` | Inventory management | | Enrollments | `@unchainedshop/core-enrollments` | Subscriptions | | Quotations | `@unchainedshop/core-quotations` | Request for quote (RFQ) | | Bookmarks | `@unchainedshop/core-bookmarks` | Wishlists | | Worker | `@unchainedshop/core-worker` | Background jobs | | Files | `@unchainedshop/core-files` | File metadata | | Events | `@unchainedshop/core-events` | Event history | You can modify modules through: 1. Configuration options at startup 2. Plugins (Director/Adapter pattern) 3. Custom module implementations ## Infrastructure Layer Foundation utilities used across all layers: | Package | Description | |---------|-------------| | `@unchainedshop/mongodb` | Database lifecycle, queries, migrations | | `@unchainedshop/events` | Event emitter with pluggable backends | | `@unchainedshop/logger` | High-performance logging | | `@unchainedshop/utils` | Common utilities and base classes | | `@unchainedshop/roles` | Role-based access control (RBAC) | | `@unchainedshop/file-upload` | File storage adapters | ## API Design Principles 1. **Stateless**: All data stored in MongoDB, no server-side sessions 2. **Guest Users**: Anonymous users use `loginAsGuest` mutation for cart operations 3. **Server-side Logic**: All business logic remains server-side for omni-channel support ### Implications **Carts as Open Orders** - Carts are stored server-side as orders with `status: null` - Users can add items on one device and checkout on another - After checkout, the cart becomes an immutable order **User Conversion** - Anonymous users can register without losing order history - Carts merge when a user logs in during purchase - Bookmarks and preferences are preserved ## Package Dependency Graph ```mermaid flowchart TD platform["@unchainedshop/platform"] --> api["@unchainedshop/api"] api --> core["@unchainedshop/core"] core --> products["core-products"] core --> orders["core-orders"] core --> users["core-users"] core --> payment["core-payment"] core --> delivery["core-delivery"] core --> assortments["core-assortments"] core --> filters["core-filters"] core --> warehousing["core-warehousing"] core --> enrollments["core-enrollments"] core --> quotations["core-quotations"] core --> bookmarks["core-bookmarks"] core --> worker["core-worker"] core --> files["core-files"] core --> events["core-events"] ``` ## Next Steps - Learn about the [Director/Adapter Pattern](./director-adapter-pattern.md) for extending functionality - Understand the [Order Lifecycle](./order-lifecycle.md) for checkout implementation - Explore the [Pricing System](./pricing-system.md) for custom pricing logic --- ## Authentication(Concepts) Unchained Engine supports multiple authentication patterns to accommodate different user flows and integration requirements. ## Authentication Strategies | Strategy | Use Case | |----------|----------| | **Guest** | Anonymous browsing and checkout | | **Email/Password** | Traditional user registration | | **WebAuthn** | Passwordless authentication | | **OIDC** | External identity providers (Google, Keycloak, etc.) | | **API Token** | Machine-to-machine authentication | ## Anonymous vs Guest Users Unchained distinguishes between anonymous visitors and guest users: | Type | Can Browse | Can Add to Cart | Can Checkout | |------|-----------|-----------------|--------------| | **Anonymous** | Yes | No | No | | **Guest** | Yes | Yes | Yes | | **Registered** | Yes | Yes | Yes | Anonymous users can browse products and assortments without authentication. To perform state-changing operations (cart, checkout), a guest or registered user session is required. ### Flow ```mermaid flowchart TD subgraph Anonymous["Anonymous Visitor"] B1[Browse Products] B1 -->|Add to Cart?| AUTH[Requires Auth] end AUTH --> LG[loginAsGuest] LG --> Guest subgraph Guest["Guest User"] B2[Browse] C[Add to Cart] CO[Checkout] REG[Register? - Optional] B2 --> C --> CO --> REG end ``` ### Implementation ```graphql mutation LoginAsGuest { loginAsGuest { _id tokenExpires } } ``` The session token is set as an HTTP-only cookie automatically. For subsequent requests, ensure cookies are sent with your requests. ```graphql mutation AddToCart { addCartProduct(productId: "...", quantity: 1) { _id } } ``` ```graphql mutation Checkout { checkoutCart { _id orderNumber } } ``` ### Guest to Registered Conversion Guests can register without losing their cart or order history: ```graphql mutation CreateUser { createUser( email: "user@example.com" password: "securepassword" ) { _id tokenExpires } } ``` The new account inherits: - Current cart - Order history - Bookmarks - Preferences ## Email/Password Authentication Traditional authentication with email and password. ### Registration ```graphql mutation CreateUserWithProfile { createUser( email: "user@example.com" password: "securepassword" profile: { displayName: "John Doe" } ) { _id tokenExpires user { _id primaryEmail { address } profile { displayName } } } } ``` ### Login ```graphql mutation Login { loginWithPassword( email: "user@example.com" password: "securepassword" ) { _id tokenExpires user { _id primaryEmail { address } } } } ``` ### Password Reset ```graphql mutation ForgotPassword { forgotPassword(email: "user@example.com") { success } } ``` Reset with token (from email): ```graphql mutation ResetPassword { resetPassword( token: "reset-token-from-email" newPassword: "newpassword" ) { _id tokenExpires } } ``` ### Change Password ```graphql mutation { changePassword( oldPassword: "currentpassword" newPassword: "newpassword" ) { success } } ``` ## WebAuthn (Passwordless) Unchained Engine supports WebAuthn for passwordless authentication using biometrics or security keys. ### Registration Flow 1. Get registration options (returns JSON with challenge, rp, user, pubKeyCredParams, etc.): ```graphql mutation GetCredentialCreationOptions { createWebAuthnCredentialCreationOptions(username: "user@example.com") } ``` 2. Create credential with browser WebAuthn API using the returned options: ```javascript const credential = await navigator.credentials.create({ publicKey: creationOptions }); ``` 3. Store the credential: ```graphql mutation AddWebAuthnCredentials($credentials: JSON!) { addWebAuthnCredentials(credentials: $credentials) { _id webAuthnCredentials { _id } } } ``` ### Authentication Flow 1. Get authentication options (returns JSON with challenge, rpId, allowCredentials, etc.): ```graphql mutation GetCredentialRequestOptions { createWebAuthnCredentialRequestOptions(username: "user@example.com") } ``` 2. Authenticate with browser WebAuthn API: ```javascript const credential = await navigator.credentials.get({ publicKey: requestOptions }); ``` 3. Verify and login: ```graphql mutation LoginWithWebAuthn($credentials: JSON!) { loginWithWebAuthn(webAuthnPublicKeyCredentials: $credentials) { _id tokenExpires user { _id } } } ``` ## OIDC (External Identity Providers) Integrate with external identity providers using OpenID Connect. ### Supported Providers Any OIDC-compliant provider: - Google - Apple - Keycloak - Zitadel - Auth0 - Azure AD - Custom providers ### Configuration OIDC integration is configured through the GraphQL context. See the [OIDC Example](https://github.com/unchainedshop/unchained/tree/master/examples/oidc) for a complete implementation using `startPlatform`'s `context` parameter to add custom authentication logic. ### Login Flow OIDC authentication is implemented via custom GraphQL resolvers. The flow typically involves: 1. **Get authorization URL**: Custom query that returns the provider's OAuth URL 2. **User redirected to provider**: User authenticates with the identity provider 3. **Exchange code for token**: Custom mutation that validates the authorization code and creates a session See the [OIDC Example](https://github.com/unchainedshop/unchained/tree/master/examples/oidc) for a complete implementation showing how to add custom OIDC queries and mutations. ## API Token Authentication For server-to-server or automated access. ### Using Tokens Unchained users have a `tokens` field that stores authentication tokens. You can query a user's tokens: ```graphql query MyTokens { me { tokens { _id } } } ``` ### Invalidating Tokens To invalidate a token: ```graphql mutation InvalidateToken { invalidateToken(tokenId: "token-id") { _id } } ``` ### Including Token in Requests Include the token in the Authorization header: ```http Authorization: Bearer ``` ## Session Management ### Token Format Unchained uses JWT tokens for authentication. Configure the token secret via environment variable: ```bash UNCHAINED_TOKEN_SECRET=your-32-character-minimum-secret-here ``` ### Logout ```graphql mutation { logout { success } } ``` ### Current User ```graphql query CurrentUser { me { _id primaryEmail { address } username profile { displayName address { firstName lastName company city postalCode countryCode } } roles cart { _id } } } ``` ## Role-Based Access Control Unchained uses RBAC for authorization: ### Built-in Roles | Role | Description | |------|-------------| | `admin` | Full access to all operations | | `user` | Authenticated user with standard permissions | ### Checking Permissions ```typescript // In a resolver if (!checkAction(context, 'manageOrders')) { throw new Error('Permission denied'); } ``` ### Custom Roles ```typescript // Define custom role const supportRole = new Role('support'); supportRole.allow('viewOrders', () => true); supportRole.allow('updateOrderStatus', (context, { order }) => { // Only pending orders return order.status === 'PENDING'; }); Roles.registerRole(supportRole); // Assign role to user await modules.users.updateRoles(userId, ['support']); ``` ## Security Best Practices For comprehensive security documentation, see the [Security Guide](../deployment/security). ### Cryptographic Standards Unchained uses industry-standard cryptography for authentication: | Operation | Algorithm | Details | |-----------|-----------|---------| | Password Hashing | PBKDF2-SHA512 | 300,000 iterations, 16-byte salt | | Token Storage | SHA-256 | Tokens hashed before database storage | | Token Generation | CSPRNG | `crypto.randomUUID()` | | Session Encryption | AES-256-GCM | Optional, via kruptein | ### 1. Token Secret Use a strong, unique secret: ```bash UNCHAINED_TOKEN_SECRET=your-32-character-minimum-secret-here ``` ### 2. HTTPS Only Always use HTTPS in production. When connecting Unchained to your web server framework, configure secure cookies: ```typescript // When using Fastify connect(fastify, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== 'production', }); ``` ### 3. Rate Limiting Implement rate limiting for authentication endpoints: ```typescript const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts message: 'Too many login attempts', }); app.use('/graphql', authLimiter); ``` ### 4. Password Requirements Configure password validation through the users module options: ```typescript await startPlatform({ options: { users: { validatePassword: async (password: string) => { if (password.length < 8) { throw new Error('Password must be at least 8 characters'); } if (!/[A-Z]/.test(password)) { throw new Error('Password must contain an uppercase letter'); } if (!/[0-9]/.test(password)) { throw new Error('Password must contain a number'); } return true; }, }, }, }); ``` ## Related - [Security Guide](../deployment/security) - Security features and compliance - [Users Module](../platform-configuration/modules/users.md) - User configuration options - [Admin UI](../admin-ui/overview.md) - Admin UI overview --- ## Director/Adapter Pattern The Director/Adapter pattern is the foundation of Unchained Engine's extensibility. Understanding this pattern is essential for customizing payment processing, delivery, pricing, and other behaviors. ## Overview **Directors** are singleton factories that manage collections of adapters. They provide methods to register, unregister, and retrieve adapters. **Adapters** implement specific behaviors and are registered with directors. When functionality is needed, the director selects and invokes the appropriate adapter(s). ```mermaid flowchart LR subgraph Director A1[Adapter 1] A2[Adapter 2] A3[Adapter 3] end ``` ## Available Directors | Director | Purpose | Example Adapters | |----------|---------|------------------| | `PaymentDirector` | Payment processing | Stripe, PayPal, Invoice | | `DeliveryDirector` | Shipping/delivery | Post, Store Pickup, Digital | | `WarehousingDirector` | Inventory management | Stock, NFT Minting | | `WorkerDirector` | Background jobs | Email, SMS, HTTP Webhooks | | `FilterDirector` | Product search | Full-text, Strict Equal | | `ProductPricingDirector` | Product prices | Base price, Tax, Discount | | `OrderPricingDirector` | Order totals | Items, Delivery, Payment | | `DeliveryPricingDirector` | Delivery fees | Flat rate, Weight-based | | `PaymentPricingDirector` | Payment fees | Card fees, Invoice fees | | `OrderDiscountDirector` | Order discounts | Coupon codes, Auto-discounts | | `ProductDiscountDirector` | Product discounts | Bulk pricing, Member pricing | | `MessagingDirector` | Notifications | Email templates, SMS | | `QuotationDirector` | RFQ processing | Manual quotes, Auto quotes | | `EnrollmentDirector` | Subscriptions | Recurring billing | ## Base Classes All adapters extend from base classes provided by `@unchainedshop/utils`: ```typescript ``` - **BaseDirector**: Factory function creating a director with adapter management - **BaseAdapter**: Base implementation with logging and utility methods ## Creating a Custom Adapter All adapters share a common structure: ```typescript const MyAdapter = { key: 'my-adapter', // Unique identifier label: 'My Custom Adapter', // Human-readable label version: '1.0.0', // Adapter version // Optional: order of execution (lower = first) orderIndex: 10, // Adapter-specific methods... }; // Register with the appropriate director SomeDirector.registerAdapter(MyAdapter); ``` ## Payment Director Manages payment processing and orchestrates payment adapters. ```typescript const MyPaymentAdapter: IPaymentAdapter = { key: 'my-payment', label: 'My Payment Gateway', version: '1.0.0', // Which payment types this adapter supports typeSupported(type) { return type === 'CARD'; // CARD, INVOICE, or GENERIC }, actions(config, context) { return { // Return configuration errors (e.g., missing API key) configurationError() { if (!process.env.PAYMENT_API_KEY) { return { code: 'MISSING_API_KEY' }; } return null; }, // Is this adapter active for the current context? isActive() { return true; }, // Can order be confirmed before payment completes? isPayLaterAllowed() { return false; }, // Process payment charge async charge() { // Return { transactionId } on success // Return false if payment not yet complete // Throw error to abort checkout return { transactionId: '...' }; }, // Confirm a previously authorized payment async confirm() { return { transactionId: '...' }; }, // Cancel/refund a payment async cancel() { return true; }, // Register a payment method (e.g., save card) async register() { return { token: '...' }; }, // Sign payment request for client-side SDK async sign() { return '...'; }, // Validate a payment token async validate(token) { return true; }, }; }, }; PaymentDirector.registerAdapter(MyPaymentAdapter); ``` ## Delivery Director Manages delivery operations and coordinates shipping adapters. ```typescript const MyDeliveryAdapter: IDeliveryAdapter = { key: 'my-delivery', label: 'My Shipping Provider', version: '1.0.0', // Which delivery types this adapter supports typeSupported(type) { return type === 'SHIPPING'; // SHIPPING, PICKUP, or DELIVERY }, actions(config, context) { return { configurationError() { return null; }, isActive() { return true; }, // Can order be auto-released for delivery? isAutoReleaseAllowed() { return false; }, // Trigger delivery async send() { return { trackingNumber: '...' }; }, // Estimated delivery time in milliseconds estimatedDeliveryThroughput(warehousingTime) { return 3 * 24 * 60 * 60 * 1000; // 3 days }, // For PICKUP type: available locations async pickUpLocations() { return []; }, async pickUpLocationById(locationId) { return null; }, }; }, }; DeliveryDirector.registerAdapter(MyDeliveryAdapter); ``` ## Warehousing Director Manages inventory and stock operations, including NFT/token support. ```typescript const MyWarehousingAdapter: IWarehousingAdapter = { key: 'my-warehouse', label: 'My Inventory System', version: '1.0.0', typeSupported(type) { return type === 'PHYSICAL'; }, actions(config, context) { return { configurationError() { return null; }, isActive() { return true; }, // Current stock quantity async stock(referenceDate) { return 100; }, // Production time in ms (for made-to-order) async productionTime(quantity) { return 0; }, // Time to prepare for shipping in ms async commissioningTime(quantity) { return 24 * 60 * 60 * 1000; // 1 day }, async estimatedStock() { return 100; }, async estimatedDispatch() { return new Date(); }, // For tokenized products (NFTs): async tokenize() { return []; }, async tokenMetadata(serial, date) { return {}; }, async isInvalidateable(serial, date) { return false; }, }; }, }; WarehousingDirector.registerAdapter(MyWarehousingAdapter); ``` ## Worker Director Manages background job processing and scheduled tasks. ```typescript interface MyInput { email: string; subject: string; } interface MyOutput { messageId: string; } const MyWorkerAdapter: IWorkerAdapter = { key: 'my-worker', label: 'My Background Worker', version: '1.0.0', type: 'MY_WORK_TYPE', // Work type identifier external: false, // Runs in-process maxParallelAllocations: 10, // Max concurrent executions async doWork(input, unchainedAPI, workId) { const { email, subject } = input; // Process the work item // ... return { success: true, result: { messageId: 'msg-123' }, }; }, }; WorkerDirector.registerAdapter(MyWorkerAdapter); ``` ### Scheduling Recurring Work ```typescript WorkerDirector.configureAutoscheduling({ type: 'MY_WORK_TYPE', input: { email: 'test@example.com', subject: 'Test' }, schedule: '0 * * * *', // Every hour (cron syntax) }); ``` ## Pricing Directors Pricing directors calculate prices using a **chain of adapters**. Each adapter can add, modify, or discount prices. Adapters execute in order of their `orderIndex`. ```typescript const MyPricingAdapter: IProductPricingAdapter = { key: 'my-pricing', label: 'My Pricing Logic', version: '1.0.0', orderIndex: 10, // Lower numbers run first // Should this adapter run for the current context? isActivatedFor(context) { return true; }, actions(params, pricingAdapter) { return { calculate() { // Access existing calculations const { calculation } = pricingAdapter; // Add price item pricingAdapter.resultSheet().addItem({ category: 'BASE', amount: 1000, // in smallest currency unit (cents) isTaxable: true, isNetPrice: true, }); // Continue chain return pricingAdapter.calculate(); }, }; }, }; ProductPricingDirector.registerAdapter(MyPricingAdapter); ``` ### Pricing Categories | Category | Description | |----------|-------------| | `BASE` | Base product price | | `TAX` | Tax amount | | `DISCOUNT` | Discount amount (negative) | | `DELIVERY` | Delivery fee | | `PAYMENT` | Payment fee | ## Discount Directors Discount directors manage coupon codes and automatic discounts. ```typescript const MyDiscountAdapter: IDiscountAdapter = { key: 'my-discount', label: 'My Discount System', version: '1.0.0', orderIndex: 10, // Allow manual code entry starting with 'PROMO' isManualAdditionAllowed(code) { return code.startsWith('PROMO'); }, isManualRemovalAllowed() { return true; }, actions(context) { return { // Auto-apply discount without code? isValidForSystemTriggering() { return false; }, // Apply when specific code entered? isValidForCodeTriggering(code) { return code === 'PROMO10'; }, // Return discount configuration discountForPricingAdapterKey(params) { return { isNetPrice: false, rate: 0.1, // 10% off }; }, // Reserve discount (e.g., decrement coupon balance) async reserve(code) {}, // Release reservation on order cancellation async release() {}, }; }, }; OrderDiscountDirector.registerAdapter(MyDiscountAdapter); ``` ## Filter Director Manages product filtering and search functionality. ```typescript const MyFilterAdapter: IFilterAdapter = { key: 'my-filter', label: 'My Search Filter', version: '1.0.0', orderIndex: 10, actions(context) { return { // Return product IDs matching filter async aggregateProductIds(params) { return ['product-1', 'product-2']; }, // Search products async searchProducts(params, options) { return { productIds: [], totalCount: 0 }; }, // Search assortments async searchAssortments(params, options) { return { assortmentIds: [], totalCount: 0 }; }, // Modify MongoDB product selector transformProductSelector(selector, options) { return selector; }, // Modify MongoDB filter selector transformFilterSelector(selector, options) { return selector; }, // Modify MongoDB sort stage transformSortStage(sort, options) { return sort; }, }; }, }; FilterDirector.registerAdapter(MyFilterAdapter); ``` ## Messaging Director The Messaging Director uses a template resolver pattern for notifications. ```typescript // Register a message template MessagingDirector.registerTemplate('ORDER_CONFIRMATION', async (context) => { const { order, user } = context; return [ { type: 'EMAIL', input: { to: user.email, subject: `Order Confirmation #${order.orderNumber}`, html: 'Thank you for your order!', }, }, { type: 'SMS', input: { to: user.phone, text: `Order #${order.orderNumber} confirmed!`, }, }, ]; }); ``` ## Quotation Director Handles quotation/RFQ (Request for Quote) operations. ```typescript const MyQuotationAdapter: IQuotationAdapter = { key: 'my-quotation', label: 'My Quote System', version: '1.0.0', isActivatedFor(quotationContext, unchainedAPI) { return true; }, actions(context) { return { configurationError() { return null; }, // Require manual quote creation? isManualProposalRequired() { return true; }, // Require manual request verification? isManualRequestVerificationRequired() { return false; }, // Generate quote async quote() { return { price: 1000, currency: 'CHF' }; }, async submitRequest(quotationContext) {}, async verifyRequest(quotationContext) {}, async rejectRequest(quotationContext) {}, transformItemConfiguration(params) { return params.configuration; }, }; }, }; QuotationDirector.registerAdapter(MyQuotationAdapter); ``` ## Enrollment Director Manages subscription/enrollment plans and recurring billing. ```typescript const MyEnrollmentAdapter: IEnrollmentAdapter = { key: 'my-enrollment', label: 'My Subscription System', version: '1.0.0', isActivatedFor(productPlan) { return productPlan.type === 'PLAN_PRODUCT'; }, transformOrderItemToEnrollmentPlan(orderPosition, unchainedAPI) { return { configuration: orderPosition.configuration, }; }, actions(context) { return { // Calculate next billing period async nextPeriod() { return { start: new Date(), end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), }; }, isValidForActivation() { return true; }, isOverdue() { return false; }, // Order configuration for billing period async configurationForOrder(period) { return {}; }, }; }, }; EnrollmentDirector.registerAdapter(MyEnrollmentAdapter); ``` ## Best Practices ### 1. Unique Keys Always use unique, namespaced keys for your adapters: ```typescript key: 'com.mycompany.payment.stripe-custom' ``` ### 2. Error Handling Return `configurationError()` for missing configuration rather than throwing: ```typescript configurationError() { if (!process.env.API_KEY) { return { code: 'MISSING_API_KEY', message: 'API key required' }; } return null; } ``` ### 3. Async Operations in Workers For long-running operations, use the Worker system instead of blocking adapters: ```typescript // In delivery adapter async send() { // Queue work instead of blocking await context.modules.worker.addWork({ type: 'EXTERNAL_SHIPPING_API', input: { orderId: order._id }, }); return false; // Not complete yet } ``` ### 4. Order Index Use appropriate `orderIndex` values for pricing adapters: - 0-10: Base price calculation - 10-20: Discounts - 20-30: Tax calculation - 30+: Final adjustments ## Related - [Payment Plugins](../plugins/payment/stripe) - Payment adapters - [Delivery Plugins](../plugins/) - Delivery adapters - [Filter Plugins](../plugins/) - Filter adapters - [Warehousing Plugins](../plugins/) - Warehousing adapters - [Pricing System](../concepts/pricing-system) - Pricing adapters and chain - [Worker](../extend/worker.md) - Background job processing --- ## GraphQL API Reference Unchained Engine exposes a comprehensive GraphQL API built with [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server). The API is available at the `/graphql` endpoint. :::tip Interactive Explorer Use the [GraphQL Playground](https://engine.unchained.shop/graphql) to explore the schema interactively with auto-completion and documentation. ::: ## Custom Scalars | Scalar | Description | |--------|-------------| | `JSON` | Arbitrary JSON object | | `DateTime` | ISO 8601 date-time string | | `Date` | Date value | | `Timestamp` | Unix timestamp (integer) | | `LowerCaseString` | String that enforces lowercase | | `Locale` | BCP 47 locale code (e.g., `en`, `de-CH`) | ## Directives ### `@cacheControl` Controls HTTP caching behavior for fields and types: ```graphql directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT ``` Scope values: `PUBLIC`, `PRIVATE` ## Queries ### Products | Query | Arguments | Description | |-------|-----------|-------------| | `product` | `productId: ID`, `slug: String` | Get product by ID or slug | | `products` | `queryString`, `tags`, `slugs`, `limit`, `offset`, `includeDrafts`, `sort` | List products | | `productsCount` | `tags`, `slugs`, `includeDrafts`, `queryString` | Count products | | `productCatalogPrices` | `productId: ID!` | Get catalog prices | | `productReview` | `productReviewId: ID!` | Get a product review | | `productReviews` | `limit`, `offset`, `sort`, `queryString` | List all reviews | | `productReviewsCount` | `queryString` | Count reviews | | `searchProducts` | `queryString`, `filterQuery`, `assortmentId`, `orderBy`, `includeInactive`, `ignoreChildAssortments` | Search products with filters | | `translatedProductTexts` | `productId: ID!` | Get all translations | | `translatedProductMediaTexts` | `productMediaId: ID!` | Get media translations | | `translatedProductVariationTexts` | `productVariationId: ID!`, `productVariationOptionValue: String` | Get variation translations | ### Orders | Query | Arguments | Description | |-------|-----------|-------------| | `order` | `orderId: ID!` | Get order by ID | | `orders` | `limit`, `offset`, `includeCarts`, `queryString`, `status`, `sort`, `paymentProviderIds`, `deliveryProviderIds`, `dateRange` | List orders | | `ordersCount` | `includeCarts`, `queryString`, `paymentProviderIds`, `deliveryProviderIds`, `dateRange`, `status` | Count orders | ### Users | Query | Arguments | Description | |-------|-----------|-------------| | `me` | β€” | Current authenticated user | | `impersonator` | β€” | User impersonating current user | | `user` | `userId: ID` | Get user (defaults to current user) | | `users` | `limit`, `offset`, `includeGuests`, `queryString`, `sort`, `emailVerified`, `lastLogin`, `tags` | List users | | `usersCount` | `includeGuests`, `queryString`, `emailVerified`, `lastLogin`, `tags` | Count users | | `validateResetPasswordToken` | `token: String!` | Validate reset token | | `validateVerifyEmailToken` | `token: String!` | Validate email token | ### Assortments | Query | Arguments | Description | |-------|-----------|-------------| | `assortment` | `assortmentId: ID`, `slug: String` | Get assortment by ID or slug | | `assortments` | `queryString`, `tags`, `slugs`, `limit`, `offset`, `includeInactive`, `includeLeaves`, `sort` | List assortments | | `assortmentsCount` | `tags`, `slugs`, `includeInactive`, `includeLeaves`, `queryString` | Count assortments | | `searchAssortments` | `queryString`, `assortmentIds`, `orderBy`, `includeInactive` | Search assortments | | `translatedAssortmentTexts` | `assortmentId: ID!` | Get translations | | `translatedAssortmentMediaTexts` | `assortmentMediaId: ID!` | Get media translations | ### Filters | Query | Arguments | Description | |-------|-----------|-------------| | `filter` | `filterId: ID` | Get filter by ID | | `filters` | `limit`, `offset`, `includeInactive`, `queryString`, `sort` | List filters | | `filtersCount` | `includeInactive`, `queryString` | Count filters | | `translatedFilterTexts` | `filterId: ID!`, `filterOptionValue: String` | Get translations | ### Localization | Query | Arguments | Description | |-------|-----------|-------------| | `language` | `languageId: ID!` | Get language | | `languages` | `limit`, `offset`, `includeInactive`, `queryString`, `sort` | List languages | | `languagesCount` | `includeInactive`, `queryString` | Count languages | | `country` | `countryId: ID!` | Get country | | `countries` | `limit`, `offset`, `includeInactive`, `queryString`, `sort` | List countries | | `countriesCount` | `includeInactive`, `queryString` | Count countries | | `currency` | `currencyId: ID!` | Get currency | | `currencies` | `limit`, `offset`, `includeInactive`, `queryString`, `sort` | List currencies | | `currenciesCount` | `includeInactive`, `queryString` | Count currencies | ### Providers | Query | Arguments | Description | |-------|-----------|-------------| | `paymentProvider` | `paymentProviderId: ID!` | Get payment provider | | `paymentProviders` | `type: PaymentProviderType` | List payment providers | | `paymentProvidersCount` | `type: PaymentProviderType` | Count payment providers | | `paymentInterfaces` | `type: PaymentProviderType` | List available payment interfaces | | `deliveryProvider` | `deliveryProviderId: ID!` | Get delivery provider | | `deliveryProviders` | `type: DeliveryProviderType` | List delivery providers | | `deliveryProvidersCount` | `type: DeliveryProviderType` | Count delivery providers | | `deliveryInterfaces` | `type: DeliveryProviderType` | List available delivery interfaces | | `warehousingProvider` | `warehousingProviderId: ID!` | Get warehousing provider | | `warehousingProviders` | `type: WarehousingProviderType` | List warehousing providers | | `warehousingProvidersCount` | `type: WarehousingProviderType` | Count warehousing providers | | `warehousingInterfaces` | `type: WarehousingProviderType` | List available warehousing interfaces | ### Quotations & Enrollments | Query | Arguments | Description | |-------|-----------|-------------| | `quotation` | `quotationId: ID!` | Get quotation | | `quotations` | `limit`, `offset`, `queryString`, `sort` | List quotations | | `quotationsCount` | `queryString` | Count quotations | | `enrollment` | `enrollmentId: ID!` | Get enrollment | | `enrollments` | `limit`, `offset`, `queryString`, `status`, `sort` | List enrollments | | `enrollmentsCount` | `queryString`, `status` | Count enrollments | ### Tokens | Query | Arguments | Description | |-------|-----------|-------------| | `token` | `tokenId: ID!` | Get token | | `tokens` | `queryString`, `limit`, `offset` | List tokens | | `tokensCount` | `queryString` | Count tokens | ### Work Queue & Events | Query | Arguments | Description | |-------|-----------|-------------| | `work` | `workId: ID!` | Get work item | | `workQueue` | `limit`, `offset`, `status`, `created`, `queryString`, `sort`, `types` | List work queue | | `workQueueCount` | `status`, `types`, `created`, `queryString` | Count work items | | `activeWorkTypes` | β€” | List registered worker types | | `event` | `eventId: ID!` | Get event | | `events` | `types`, `limit`, `offset`, `queryString`, `created`, `sort` | List events | | `eventsCount` | `types`, `queryString`, `created` | Count events | ### Statistics & System | Query | Arguments | Description | |-------|-----------|-------------| | `shopInfo` | β€” | Shop configuration and default locale | | `orderStatistics` | `dateRange` | Order analytics | | `eventStatistics` | `types`, `dateRange` | Event analytics | | `workStatistics` | `types`, `dateRange` | Worker analytics | ## Mutations ### Authentication | Mutation | Arguments | Description | |----------|-----------|-------------| | `loginWithPassword` | `username`, `email`, `password!` | Login with credentials | | `loginWithWebAuthn` | `webAuthnPublicKeyCredentials: JSON!` | Login with WebAuthn | | `loginAsGuest` | β€” | Create anonymous session | | `logout` | β€” | End current session | | `logoutAllSessions` | β€” | Invalidate all tokens | | `impersonate` | `userId: ID!` | Impersonate a user | | `stopImpersonation` | β€” | End impersonation | | `createUser` | `username`, `email`, `password`, `profile`, `webAuthnPublicKeyCredentials` | Register user | | `changePassword` | `oldPassword!`, `newPassword!` | Change password | | `forgotPassword` | `email: String!` | Request password reset | | `resetPassword` | `newPassword!`, `token!` | Reset with token | | `verifyEmail` | `token: String!` | Verify email address | | `sendVerificationEmail` | `email` | Resend verification | | `enrollUser` | `profile!`, `email!`, `password` | Enroll new user | | `sendEnrollmentEmail` | `email: String!` | Send enrollment email | | `heartbeat` | β€” | Update activity info | ### WebAuthn | Mutation | Arguments | Description | |----------|-----------|-------------| | `createWebAuthnCredentialCreationOptions` | `username!`, `extensionOptions` | Get passkey registration options | | `createWebAuthnCredentialRequestOptions` | `username`, `extensionOptions` | Get passkey login options | | `addWebAuthnCredentials` | `credentials: JSON!` | Register passkey | | `removeWebAuthnCredentials` | `credentialsId: ID!` | Remove passkey | ### User Management | Mutation | Arguments | Description | |----------|-----------|-------------| | `updateUserProfile` | `profile`, `meta`, `userId` | Update user profile | | `removeUser` | `userId`, `removeUserReviews` | Delete user | | `addEmail` | `email!`, `userId` | Add email address | | `removeEmail` | `email!`, `userId` | Remove email address | | `setUserTags` | `tags!`, `userId!` | Set user tags | | `setUsername` | `username!`, `userId!` | Set username | | `setPassword` | `newPassword!`, `userId!` | Set password | | `setRoles` | `roles!`, `userId!` | Set user roles | | `addPushSubscription` | `subscription!`, `unsubscribeFromOtherUsers` | Add push subscription | | `removePushSubscription` | `p256dh: String!` | Remove push subscription | | `pageView` | `path!`, `referrer` | Log page view | ### Web3 | Mutation | Arguments | Description | |----------|-----------|-------------| | `addWeb3Address` | `address: String!` | Add blockchain address | | `removeWeb3Address` | `address: String!` | Remove blockchain address | | `verifyWeb3Address` | `address!`, `hash!` | Verify blockchain address | ### Cart & Checkout | Mutation | Arguments | Description | |----------|-----------|-------------| | `createCart` | `orderNumber: String!` | Create alternative cart | | `addCartProduct` | `orderId`, `productId!`, `quantity`, `configuration` | Add product to cart | | `addMultipleCartProducts` | `orderId`, `items!` | Add multiple products | | `addCartDiscount` | `orderId`, `code!` | Apply discount code | | `addCartQuotation` | `orderId`, `quotationId!`, `quantity`, `configuration` | Add quotation to cart | | `updateCart` | `orderId`, `billingAddress`, `contact`, `meta`, `paymentProviderId`, `deliveryProviderId` | Update cart details | | `emptyCart` | `orderId` | Remove all items | | `updateCartItem` | `itemId!`, `quantity`, `configuration` | Update cart item | | `removeCartItem` | `itemId: ID!` | Remove cart item | | `removeCartDiscount` | `discountId: ID!` | Remove discount | | `updateCartDeliveryShipping` | `orderId`, `deliveryProviderId!`, `address`, `meta` | Set shipping delivery | | `updateCartDeliveryPickUp` | `orderId`, `deliveryProviderId!`, `orderPickUpLocationId!`, `meta` | Set pickup delivery | | `updateCartPaymentInvoice` | `orderId`, `paymentProviderId!`, `meta` | Set invoice payment | | `updateCartPaymentGeneric` | `orderId`, `paymentProviderId!`, `meta` | Set generic payment | | `checkoutCart` | `orderId`, `paymentContext`, `deliveryContext` | Process checkout | ### Order Administration | Mutation | Arguments | Description | |----------|-----------|-------------| | `removeOrder` | `orderId: ID!` | Remove open order | | `confirmOrder` | `orderId!`, `paymentContext`, `deliveryContext`, `comment` | Confirm order | | `rejectOrder` | `orderId!`, `paymentContext`, `deliveryContext`, `comment` | Reject order | | `payOrder` | `orderId: ID!` | Mark order as paid | | `deliverOrder` | `orderId: ID!` | Mark order as delivered | | `signPaymentProviderForCheckout` | `orderPaymentId`, `transactionContext` | Sign payment | ### Product Management | Mutation | Arguments | Description | |----------|-----------|-------------| | `createProduct` | `product!`, `texts` | Create product | | `updateProduct` | `productId!`, `product!` | Update product | | `publishProduct` | `productId: ID!` | Publish product | | `unpublishProduct` | `productId: ID!` | Unpublish product | | `removeProduct` | `productId: ID!` | Delete product | | `updateProductCommerce` | `productId!`, `commerce!` | Update pricing info | | `updateProductSupply` | `productId!`, `supply!` | Update supply info | | `updateProductPlan` | `productId!`, `plan!` | Update plan info | | `updateProductWarehousing` | `productId!`, `warehousing!` | Update warehousing info | | `updateProductTokenization` | `productId!`, `tokenization!` | Update tokenization | | `updateProductTexts` | `productId!`, `texts!` | Update translations | | `updateProductMediaTexts` | `productMediaId!`, `texts!` | Update media texts | | `removeProductMedia` | `productMediaId: ID!` | Remove media | | `reorderProductMedia` | `sortKeys!` | Reorder media | | `createProductVariation` | `productId!`, `variation!`, `texts` | Create variation | | `removeProductVariation` | `productVariationId: ID!` | Remove variation | | `updateProductVariationTexts` | `productVariationId!`, `productVariationOptionValue`, `texts!` | Update variation texts | | `createProductVariationOption` | `productVariationId!`, `option!`, `texts` | Add variation option | | `removeProductVariationOption` | `productVariationId!`, `productVariationOptionValue!` | Remove option | | `createProductBundleItem` | `productId!`, `item!` | Add bundle item | | `removeBundleItem` | `productId!`, `index!` | Remove bundle item | | `addProductAssignment` | `proxyId!`, `productId!`, `vectors!` | Link variant | | `removeProductAssignment` | `proxyId!`, `vectors!` | Unlink variant | ### Assortment Management | Mutation | Arguments | Description | |----------|-----------|-------------| | `createAssortment` | `assortment!`, `texts` | Create assortment | | `updateAssortment` | `assortment!`, `assortmentId!` | Update assortment | | `removeAssortment` | `assortmentId: ID!` | Delete assortment | | `updateAssortmentTexts` | `assortmentId!`, `texts!` | Update translations | | `addAssortmentProduct` | `assortmentId!`, `productId!`, `tags` | Add product | | `removeAssortmentProduct` | `assortmentProductId: ID!` | Remove product | | `reorderAssortmentProducts` | `sortKeys!` | Reorder products | | `addAssortmentLink` | `parentAssortmentId!`, `childAssortmentId!`, `tags` | Link assortments | | `removeAssortmentLink` | `assortmentLinkId: ID!` | Unlink assortments | | `reorderAssortmentLinks` | `sortKeys!` | Reorder links | | `addAssortmentFilter` | `assortmentId!`, `filterId!`, `tags` | Add filter | | `removeAssortmentFilter` | `assortmentFilterId: ID!` | Remove filter | | `reorderAssortmentFilters` | `sortKeys!` | Reorder filters | | `removeAssortmentMedia` | `assortmentMediaId: ID!` | Remove media | | `reorderAssortmentMedia` | `sortKeys!` | Reorder media | | `updateAssortmentMediaTexts` | `assortmentMediaId!`, `texts!` | Update media texts | ### Filter Management | Mutation | Arguments | Description | |----------|-----------|-------------| | `createFilter` | `filter!`, `texts` | Create filter | | `updateFilter` | `filter!`, `filterId!` | Update filter | | `removeFilter` | `filterId: ID!` | Delete filter | | `createFilterOption` | `filterId!`, `option!`, `texts` | Add option | | `removeFilterOption` | `filterId!`, `filterOptionValue!` | Remove option | | `updateFilterTexts` | `filterId!`, `filterOptionValue`, `texts!` | Update texts | ### Provider Management | Mutation | Arguments | Description | |----------|-----------|-------------| | `createPaymentProvider` | `paymentProvider!` | Create payment provider | | `updatePaymentProvider` | `paymentProvider!`, `paymentProviderId!` | Update payment provider | | `removePaymentProvider` | `paymentProviderId: ID!` | Delete payment provider | | `signPaymentProviderForCredentialRegistration` | `paymentProviderId!`, `transactionContext` | Sign credential registration | | `registerPaymentCredentials` | `transactionContext!`, `paymentProviderId!` | Register credentials | | `markPaymentCredentialsPreferred` | `paymentCredentialsId: ID!` | Set preferred | | `removePaymentCredentials` | `paymentCredentialsId: ID!` | Delete credentials | | `createDeliveryProvider` | `deliveryProvider!` | Create delivery provider | | `updateDeliveryProvider` | `deliveryProvider!`, `deliveryProviderId!` | Update delivery provider | | `removeDeliveryProvider` | `deliveryProviderId: ID!` | Delete delivery provider | | `createWarehousingProvider` | `warehousingProvider!` | Create warehousing provider | | `updateWarehousingProvider` | `warehousingProvider!`, `warehousingProviderId!` | Update warehousing provider | | `removeWarehousingProvider` | `warehousingProviderId: ID!` | Delete warehousing provider | ### Localization Management | Mutation | Arguments | Description | |----------|-----------|-------------| | `createLanguage` | `language!` | Create language | | `updateLanguage` | `language!`, `languageId!` | Update language | | `removeLanguage` | `languageId: ID!` | Delete language | | `createCountry` | `country!` | Create country | | `updateCountry` | `country!`, `countryId!` | Update country | | `removeCountry` | `countryId: ID!` | Delete country | | `createCurrency` | `currency!` | Create currency | | `updateCurrency` | `currency!`, `currencyId!` | Update currency | | `removeCurrency` | `currencyId: ID!` | Delete currency | ### Quotations | Mutation | Arguments | Description | |----------|-----------|-------------| | `requestQuotation` | `productId!`, `configuration` | Request for Proposal | | `verifyQuotation` | `quotationId!`, `quotationContext` | Verify eligibility | | `rejectQuotation` | `quotationId!`, `quotationContext` | Reject quotation | | `makeQuotationProposal` | `quotationId!`, `quotationContext` | Make proposal | ### Enrollments | Mutation | Arguments | Description | |----------|-----------|-------------| | `createEnrollment` | `plan!`, `billingAddress`, `contact`, `payment`, `delivery`, `meta` | Create enrollment | | `updateEnrollment` | `enrollmentId`, `plan`, `billingAddress`, `contact`, `payment`, `delivery`, `meta` | Update enrollment | | `activateEnrollment` | `enrollmentId: ID!` | Activate enrollment | | `terminateEnrollment` | `enrollmentId: ID!` | Terminate enrollment | ### Reviews | Mutation | Arguments | Description | |----------|-----------|-------------| | `createProductReview` | `productId!`, `productReview!` | Create review | | `updateProductReview` | `productReviewId!`, `productReview!` | Update review | | `removeProductReview` | `productReviewId: ID!` | Remove review | | `removeUserProductReviews` | `userId: ID!` | Remove user's reviews | | `addProductReviewVote` | `productReviewId!`, `type!`, `meta` | Add vote | | `removeProductReviewVote` | `productReviewId!`, `type` | Remove vote | ### Bookmarks | Mutation | Arguments | Description | |----------|-----------|-------------| | `bookmark` | `productId!`, `bookmarked` | Toggle bookmark | | `createBookmark` | `productId!`, `userId!`, `meta` | Create bookmark | | `removeBookmark` | `bookmarkId: ID!` | Remove bookmark | ### Work Queue | Mutation | Arguments | Description | |----------|-----------|-------------| | `addWork` | `type!`, `priority`, `input`, `originalWorkId`, `scheduled`, `retries`, `worker` | Queue work | | `allocateWork` | `types`, `worker` | Allocate next task | | `finishWork` | `workId!`, `result`, `error`, `success`, `worker`, `started`, `finished` | Complete work | | `processNextWork` | `worker` | Process next work unit | | `removeWork` | `workId: ID!` | Remove work | ### Media Upload | Mutation | Arguments | Description | |----------|-----------|-------------| | `prepareProductMediaUpload` | `mediaName!`, `productId!` | Prepare product media upload | | `prepareAssortmentMediaUpload` | `mediaName!`, `assortmentId!` | Prepare assortment media upload | | `prepareUserAvatarUpload` | `mediaName!`, `userId` | Prepare avatar upload | | `confirmMediaUpload` | `mediaUploadTicketId!`, `size!`, `type!` | Confirm upload | ### Tokens | Mutation | Arguments | Description | |----------|-----------|-------------| | `invalidateToken` | `tokenId: ID!` | Invalidate token | | `exportToken` | `tokenId!`, `quantity`, `recipientWalletAddress!` | Export token | ## Enums | Enum | Values | |------|--------| | `ProductType` | `SIMPLE_PRODUCT`, `CONFIGURABLE_PRODUCT`, `BUNDLE_PRODUCT`, `PLAN_PRODUCT`, `TOKENIZED_PRODUCT` | | `ProductStatus` | `DRAFT`, `ACTIVE`, `DELETED` | | `OrderStatus` | `OPEN`, `PENDING`, `CONFIRMED`, `FULFILLED`, `REJECTED` | | `PaymentProviderType` | `INVOICE`, `GENERIC` | | `DeliveryProviderType` | `SHIPPING`, `PICKUP` | | `WarehousingProviderType` | `PHYSICAL`, `VIRTUAL` | | `FilterType` | `SWITCH`, `SINGLE_CHOICE`, `MULTI_CHOICE`, `RANGE` | | `QuotationStatus` | `REQUESTED`, `PROCESSING`, `PROPOSED`, `FULFILLED`, `REJECTED` | | `EnrollmentStatus` | `INITIAL`, `ACTIVE`, `PAUSED`, `TERMINATED` | | `WorkStatus` | `NEW`, `ALLOCATED`, `SUCCESS`, `FAILED`, `DELETED` | | `SortDirection` | `ASC`, `DESC` | ## Related - [Extend the GraphQL API](../extend/graphql.md) - Add custom types and resolvers - [Authentication](./authentication.md) - Authentication patterns - [Architecture](./architecture.md) - System architecture --- ## Core Concepts Before diving into implementation, understanding the foundational concepts behind Unchained Engine will help you make better architectural decisions and extend the platform effectively. ## Key Concepts ### [Architecture](./architecture.md) Learn about Unchained Engine's layered architecture, design philosophy, and how the different layers interact. ### [Director/Adapter Pattern](./director-adapter-pattern.md) The plugin system that powers Unchained Engine's extensibility. Understanding this pattern is essential for customizing payment, delivery, pricing, and other behaviors. ### [Order Lifecycle](./order-lifecycle.md) How orders transition from cart to fulfillment, including the checkout process, payment handling, and delivery. ### [Pricing System](./pricing-system.md) How prices are calculated using a chain of pricing adapters, including product pricing, delivery fees, payment fees, taxes, and discounts. ### [Authentication](./authentication.md) Authentication patterns including guest users, registered users, and external identity providers (OIDC). ## Design Philosophy Unchained Engine is built on three core principles: **Free and Open Source Software (FOSS)** - Prevents vendor lock-in - Fosters community support - Ensures resilience against economic issues **Hackable / Code-first** - Configuration through code, not control panels - Customization beyond what core developers intended - Developer is always in control **Headless / API-first** - Decoupled from any specific UI - Flexible and long-lasting architecture - Supports any frontend framework --- ## Order Lifecycle Orders in Unchained Engine follow a well-defined lifecycle from cart creation through fulfillment. Understanding this lifecycle is essential for implementing checkout flows and integrating with external systems. ## Order States ```mermaid stateDiagram-v2 [*] --> OPEN: Cart Created OPEN --> PENDING: Checkout PENDING --> CONFIRMED: Payment OK PENDING --> REJECTED: Payment Failed / Cancelled CONFIRMED --> FULFILLED: Delivery Complete ``` | Status | Description | |--------|-------------| | `null` (OPEN) | Cart - actively being modified | | `PENDING` | Checkout initiated, awaiting payment confirmation | | `CONFIRMED` | Payment confirmed, awaiting delivery | | `FULFILLED` | Delivery complete, order finished | | `REJECTED` | Order cancelled/rejected | ## Distributed Locking :::note Locking Unchained uses "Distributed Locking" during checkout, order confirmation, and order rejection. All of these services trigger the order processor state machine. ::: :::note State Persistence Every time the order processor persists an order status in the database (think "auto-save" in games), order status notification messages are triggered asynchronously. If it does not persist, the status is in memory only. ::: ## OPEN (Cart) An order starts its life with a status of `null`, indicating it's a cart. ### Key Characteristics - **User Required**: A cart always has a `userId`. For anonymous users, call `loginAsGuest` mutation first. - **Created on Demand**: Carts only exist when at least one cart mutation has been called. Before that, `Query.me.cart` returns `null`. - **Automatic Recalculation**: With every cart mutation, prices and delivery dates are recalculated. - **Side-Effect Free Reading**: Reading a cart doesn't trigger any calculations or state changes. ### Cart Behavior ```graphql mutation LoginAsGuest { loginAsGuest { _id tokenExpires } } ``` ```graphql mutation AddToCart { addCartProduct(productId: "...", quantity: 1) { _id product { _id } quantity } } ``` See [Cart Behavior](../extend/order-fulfilment/carts.md) for customization options. ## OPEN β†’ PENDING (Checkout) Checkout is typically triggered server-to-server from payment plugin webhooks. In error cases, clients may call checkout directly to analyze errors. ### Checkout Validation When checkout is initiated, the order is validated: 1. **Payment Provider**: Order must have a payment provider set 2. **Delivery Provider**: Order must have a delivery provider set 3. **Cart Items**: At least one order position must be present 4. **Position Validation**: Each position is validated via `validateOrderPosition`: - By default, checks if the product is still active - Can be customized via platform settings 5. **Quotation Check**: If position is a quotation proposal, the Quotation plugin verifies it's still valid :::warning No Recalculation The order validation step **DOES NOT** recalculate the order. Prices and delivery dates may have changed since the last cart mutation. If you need such validation, throw an error in `validateOrderPosition` and let the client application fix the problem. ::: ### Transition to PENDING After validation passes, the order enters `PENDING` status as an in-flight status (not yet persisted to database). ```graphql mutation { checkoutCart { _id status orderNumber } } ``` ## PENDING β†’ CONFIRMED (Confirmation) The system proceeds with payment processing via the `PaymentDirector`. ### Payment Processing Flow ```mermaid flowchart TD A[PaymentDirector.charge] --> B{Result?} B -->|Success| C[Check Auto-Confirm] B -->|Error Thrown| D[Abort - Stay OPEN] B -->|Returns false| E[Continue as PENDING] C -->|Blocked| F[PENDING - persisted] C -->|Allowed| G[CONFIRMED] ``` ### Payment Outcomes | `charge()` Result | Order Status | Behavior | |-------------------|--------------|----------| | Returns `{ transactionId }` | Continues to confirmation | Payment successful | | Returns `false` | Continues to confirmation | Payment not yet complete | | Throws error | Stays `OPEN` | Checkout aborted | ### Auto-Confirmation Check After payment, Unchained checks if auto-confirmation is allowed: - **Payment Plugin**: `isPayLaterAllowed()` method - **Delivery Plugin**: `isAutoReleaseAllowed()` method :::info Pay Later Example A post-paid invoice plugin typically returns `true` for `isPayLaterAllowed` because delivery can proceed without payment. A pre-paid invoice plugin typically returns `false`, blocking order confirmation until payment is received. ::: ### If Confirmation Blocked Checkout ends with order persisted as `PENDING`, waiting for: - Payment webhook events - Manual confirmation via `Mutation.confirmOrder` ### If Confirmation Allowed 1. Payment plugin's `confirm()` method is called (payment may have been only reserved) 2. Order transitions to `CONFIRMED` 3. Status is persisted to database ## PENDING β†’ REJECTED (Rejection) An order in `PENDING` state can be rejected, typically via manual API call. ```graphql mutation { rejectOrder(orderId: "...") { _id status } } ``` ### Rejection Flow 1. `PaymentDirector` calls `cancel()` on the payment adapter 2. If `cancel()` throws, order stays `PENDING` 3. Otherwise, order is persisted as `REJECTED` ## CONFIRMED β†’ FULFILLED (Fulfillment) The system proceeds with delivery via the `DeliveryDirector`. ### Delivery Processing 1. **Delivery Initiation**: `DeliveryDirector` calls `send()` on the delivery adapter - If throws: Process interrupted, order stays `CONFIRMED` - If returns `false`: Delivery not complete yet - If returns `{ trackingNumber }`: Delivery initiated 2. **Position Processing**: For each order position: - **TokenizedProduct**: `WarehousingDirector.tokenize()` creates digital tokens/NFTs - **PlanProduct**: `EnrollmentDirector.transformOrderItemToEnrollment()` creates subscriptions - **Quotation**: `QuotationDirector` marks linked quotations as fulfilled 3. **Final Status Check**: - If delivery is `DELIVERED` AND payment is `PAID` β†’ `FULFILLED` - Otherwise β†’ stays `CONFIRMED` :::danger Avoid Throwing in Plugins If any position processing throws (e.g., in a `WarehousingAdapter`), the process is interrupted in an unsupported state. Resolving this requires custom code and deep knowledge of internals. **Best Practice**: Build these actions to be asynchronous and forgiving: ```typescript async send() { // Queue work instead of blocking await context.modules.worker.addWork({ type: 'EXTERNAL_ERP_SYNC', input: { orderId: order._id }, }); return false; // Not complete yet - will be updated by worker } ``` This approach also makes checkouts faster! ::: ## Order Processor State Machine ```typescript // Simplified state machine logic async function processOrder(order) { if (order.status === null) { // Cart - ready for checkout return await checkout(order); } if (order.status === 'PENDING') { // Awaiting payment confirmation // Can transition to CONFIRMED or REJECTED } if (order.status === 'CONFIRMED') { // Awaiting delivery // Can transition to FULFILLED } } ``` ## Event Notifications Order status changes trigger asynchronous notifications: | Event | When Triggered | |-------|----------------| | `ORDER_CHECKOUT` | Order moves from OPEN to PENDING | | `ORDER_CONFIRMED` | Order confirmed | | `ORDER_REJECTED` | Order rejected | | `ORDER_FULFILLED` | Order fulfilled | | `ORDER_PAYMENT_STATUS_CHANGED` | Payment status changes | | `ORDER_DELIVERY_STATUS_CHANGED` | Delivery status changes | Subscribe to these events for: - Sending customer notifications - Syncing with external systems (ERP, CRM) - Analytics and reporting ## Sub-Statuses Orders have separate statuses for payment and delivery: ### Payment Status | Status | Description | |--------|-------------| | `OPEN` | No payment action taken | | `PAID` | Payment received | | `REFUNDED` | Payment refunded | ### Delivery Status | Status | Description | |--------|-------------| | `OPEN` | No delivery action taken | | `DELIVERED` | Delivery complete | | `RETURNED` | Items returned | ## GraphQL Queries ```graphql query GetOrder { order(orderId: "...") { _id status orderNumber payment { status } delivery { status } items { _id quantity total { amount currencyCode } } total { amount currencyCode } } } ``` ## Related - [Orders Module](../platform-configuration/modules/orders.md) - Order configuration - [Payment Plugins](../plugins/payment/stripe.md) - Payment adapters - [Delivery Plugins](../plugins/) - Delivery adapters - [Pricing System](./pricing-system.md) - Pricing chain --- ## Permissions Reference Unchained Engine uses a declarative, role-based access control (RBAC) system with 111 permission actions and context-aware evaluation. ## Built-in Roles | Role | Scope | Description | |------|-------|-------------| | `admin` | All actions | Full access to everything | | `__loggedIn__` | Own data | Authenticated users can manage their own data | | `__all__` | Public data | Public read access to products, assortments, and localization | | `__notLoggedIn__` | Auth only | Anonymous users can register and login | | `__notAdmin__` | Auto-added | Added to all non-admin authenticated users | Roles `__all__`, `__loggedIn__`, `__notLoggedIn__`, and `__notAdmin__` are **special roles** automatically assigned during permission evaluation. You don't assign them to users manually. ## Permission Actions ### View Permissions | Action | Description | |--------|-------------| | `viewProduct`, `viewProducts` | View products (public: active only) | | `viewOrder`, `viewOrders` | View orders (loggedIn: own only) | | `viewUser`, `viewUsers`, `viewUserCount` | View user data | | `viewUserRoles`, `viewUserPublicInfos`, `viewUserPrivateInfos` | View user details | | `viewUserOrders`, `viewUserQuotations`, `viewUserEnrollments`, `viewUserTokens` | View user relations | | `viewUserProductReviews` | View user's reviews | | `viewAssortment`, `viewAssortments` | View assortments (public: active only) | | `viewFilter`, `viewFilters` | View filters (public: active only) | | `viewLanguage`, `viewLanguages` | View languages (public: active only) | | `viewCountry`, `viewCountries` | View countries (public: active only) | | `viewCurrency`, `viewCurrencies` | View currencies (public: active only) | | `viewPaymentProvider`, `viewPaymentProviders`, `viewPaymentInterfaces` | View payment config | | `viewDeliveryProvider`, `viewDeliveryProviders`, `viewDeliveryInterfaces` | View delivery config | | `viewWarehousingProvider`, `viewWarehousingProviders`, `viewWarehousingInterfaces` | View warehousing config | | `viewQuotation`, `viewQuotations` | View quotations | | `viewEnrollment`, `viewEnrollments` | View enrollments | | `viewToken`, `viewTokens` | View tokens | | `viewTranslations` | View text translations | | `viewShopInfo` | View shop configuration (public) | | `viewWork`, `viewWorkQueue` | View work queue | | `viewEvent`, `viewEvents` | View events | | `viewStatistics` | View analytics | | `viewLogs` | View system logs | ### Management Permissions | Action | Description | |--------|-------------| | `manageUsers` | Full user management | | `manageProducts` | Create, update, delete products | | `manageAssortments` | Manage categories and collections | | `manageFilters` | Manage product filters | | `manageLanguages` | Manage languages | | `manageCountries` | Manage countries | | `manageCurrencies` | Manage currencies | | `managePaymentProviders` | Manage payment providers | | `manageDeliveryProviders` | Manage delivery providers | | `manageWarehousingProviders` | Manage warehousing providers | | `manageBookmarks` | Manage user bookmarks | | `manageProductReviews` | Moderate product reviews | | `manageQuotations` | Manage quotations | | `manageWorker` | Manage background jobs | | `managePaymentCredentials` | Manage saved payment methods | ### Update Permissions | Action | Description | |--------|-------------| | `updateUser` | Update user profile | | `updateUsername` | Change username | | `updateCart` | Modify cart contents | | `updateOrder` | Modify order details | | `updateOrderDelivery` | Update order delivery | | `updateOrderPayment` | Update order payment | | `updateOrderDiscount` | Manage order discounts | | `updateOrderItem` | Modify order items | | `updateProductReview` | Edit product reviews | | `updateEnrollment` | Modify enrollments | | `updateToken` | Modify tokens | ### Order Lifecycle | Action | Description | |--------|-------------| | `createCart` | Create a shopping cart | | `checkoutCart` | Process checkout | | `markOrderConfirmed` | Confirm a pending order | | `markOrderRejected` | Reject a pending order | | `markOrderPaid` | Mark order as paid | | `markOrderDelivered` | Mark order as delivered | ### Authentication | Action | Description | |--------|-------------| | `loginAsGuest` | Create anonymous session | | `loginWithPassword` | Password authentication | | `loginWithWebAuthn` | Passkey authentication | | `logout` | End session | | `logoutAllSessions` | Invalidate all tokens | | `verifyEmail` | Verify email address | | `useWebAuthn` | WebAuthn operations | | `changePassword` | Change own password | | `resetPassword` | Reset with token | | `forgotPassword` | Request reset email | | `impersonate` | Impersonate a user | | `stopImpersonation` | End impersonation | | `createUser` | Register new user | | `enrollUser` | Enroll new user | ### User Actions | Action | Description | |--------|-------------| | `reviewProduct` | Submit product review | | `voteProductReview` | Vote on reviews | | `requestQuotation` | Submit RFP | | `answerQuotation` | Respond to quotation | | `bookmarkProduct` | Bookmark/favorite products | | `registerPaymentCredentials` | Save payment methods | | `sendEmail` | Send messages | | `removeUser` | Delete user account | ### Files & Media | Action | Description | |--------|-------------| | `downloadFile` | Download files | | `uploadUserAvatar` | Upload avatar | | `uploadTempFile` | Upload temporary files | | `confirmMediaUpload` | Confirm media upload | ### Other | Action | Description | |--------|-------------| | `search` | Search products/assortments | | `pageView` | Log page views | | `heartbeat` | Update activity | | `bulkImport` | Bulk import data | ## Checking Permissions ### In GraphQL Resolvers Use the `checkResolver` decorator: ```typescript export default acl.checkResolver('viewUser')( async (root, { userId }, context) => { return context.modules.users.findUserById(userId); } ); ``` ### Field-Level Permissions Use `checkTypeResolver` for field-level access control: ```typescript export const OrderType = { deliveries: acl.checkTypeResolver('viewOrder', 'deliveries'), payments: acl.checkTypeResolver('viewOrder', 'payments'), }; ``` ### Direct Permission Check ```typescript const allowed = await Roles.userHasPermission( context, 'manageUsers', [user, { userId }], ); if (!allowed) { throw new Error('Permission denied'); } ``` ## Custom Roles ### Define a Custom Role ```typescript roles.configureRoles({ additionalRoles: { support: (role, actions) => { // View all orders role.allow(actions.viewOrder, () => true); role.allow(actions.viewOrders, () => true); // Only confirm/reject pending orders role.allow(actions.markOrderConfirmed, () => true); role.allow(actions.markOrderRejected, () => true); // View users but not modify role.allow(actions.viewUser, () => true); role.allow(actions.viewUsers, () => true); }, moderator: (role, actions) => { role.allow(actions.manageProductReviews, () => true); role.allow(actions.updateProductReview, () => true); }, }, // Register custom actions additionalActions: ['moderateContent', 'viewAnalytics'], }); ``` ### Assign Roles ```typescript // Via module API await modules.users.updateRoles(userId, ['support']); // Via GraphQL await graphqlFetch({ query: ` mutation { setRoles(userId: "user-123", roles: ["support"]) } `, }); ``` ### Context-Aware Rules Rules can inspect the user, target object, and parameters: ```typescript roles.configureRoles({ additionalRoles: { regionManager: (role, actions) => { // Only view orders from their region role.allow(actions.viewOrder, async (order, params, context) => { const user = await context.modules.users.findUserById(context.userId); return order.countryCode === user.profile?.address?.countryCode; }); }, }, }); ``` ## Permission Evaluation Rules are evaluated with **OR logic**: if any allow rule for an action returns `true`, access is granted. ``` User roles β†’ [admin, __loggedIn__, __all__] ↓ For each role, check allow rules for the action ↓ Any rule returns true β†’ ACCESS GRANTED All rules return false β†’ ACCESS DENIED ``` ## Related - [Authentication](./authentication.md) - Authentication patterns - [Custom Modules](../extend/custom-modules.md) - Secure custom modules --- ## Pricing System Unchained Engine uses a chain-of-responsibility pattern for pricing calculations. Multiple pricing adapters execute in sequence, each adding, modifying, or discounting prices. ## Overview Prices are calculated at multiple levels: ```mermaid flowchart TD subgraph Order Total PP[Product Pricing Γ— Quantity] DP[+ Delivery Pricing] PAY[+ Payment Pricing] OD[- Order Discounts] PP --> DP --> PAY --> OD end ``` | Director | Purpose | |----------|---------| | `ProductPricingDirector` | Base product price, taxes, product-level discounts | | `DeliveryPricingDirector` | Shipping and handling fees | | `PaymentPricingDirector` | Payment processing fees | | `OrderPricingDirector` | Combines all pricing, applies order-level discounts | ## Pricing Chain Adapters execute in order of their `orderIndex` (ascending). Lower numbers run first. ```mermaid flowchart LR BP[Base PriceorderIndex: 0] --> D[DiscountorderIndex: 10] --> T[TaxorderIndex: 20] ``` Each adapter: 1. Receives the current calculation state 2. Can add items to the calculation 3. Passes control to the next adapter via `super.calculate()` ### Order Index Guidelines | Range | Purpose | Examples | |-------|---------|----------| | 0-9 | Base price calculation | Catalog price, ERP integration | | 10-19 | Discounts | Member discounts, bulk pricing | | 20-29 | Tax calculation | VAT, sales tax | | 30+ | Final adjustments | Rounding, currency conversion | ## Pricing Categories | Category | Description | Typical Use | |----------|-------------|-------------| | `BASE` | Base product/service price | Initial price calculation | | `DISCOUNT` | Price reduction (negative amount) | Coupons, promotions | | `TAX` | Tax amount | VAT, sales tax | | `DELIVERY` | Shipping fees | Delivery pricing | | `PAYMENT` | Payment processing fees | Card fees, invoice fees | ## Price Item Properties When adding items to the calculation, each item has: | Property | Type | Description | |----------|------|-------------| | `amount` | number | Price in smallest currency unit (cents) | | `isTaxable` | boolean | Should tax be calculated on this amount? | | `isNetPrice` | boolean | Is this a net price (excluding tax)? | | `category` | string | Price category (BASE, TAX, DISCOUNT, etc.) | | `meta` | object | Additional metadata | ## Pricing Sheet Access calculated prices via the pricing sheet: ```typescript const pricingSheet = await modules.orders.pricingSheet(order); // Get totals const total = pricingSheet.total(); // { amount, currency } const gross = pricingSheet.gross(); // Before discounts const net = pricingSheet.net(); // After discounts, before tax const taxes = pricingSheet.taxes(); // Tax breakdown // Get items by category const discounts = pricingSheet.discounts(); const delivery = pricingSheet.delivery(); const payment = pricingSheet.payment(); // Sum specific items const taxableAmount = pricingSheet.sum({ isTaxable: true }); const baseAmount = pricingSheet.sum({ category: 'BASE' }); ``` ## GraphQL Price Fields Query product prices: ```graphql query ProductPrice($productId: ID!) { product(productId: $productId) { ... on SimpleProduct { simulatedPrice(currencyCode: "CHF", quantity: 1) { amount currencyCode isTaxable isNetPrice } } } } ``` Query cart pricing: ```graphql query CartPricing { me { cart { total { amount currencyCode } items { total { amount currencyCode } } delivery { fee { amount currencyCode } } payment { fee { amount currencyCode } } discounts { total { amount } code } } } } ``` ## Best Practices ### 1. Always Call super.calculate() ```typescript async calculate() { // Your logic here // IMPORTANT: Continue the chain return super.calculate(); } ``` Returning without calling `super.calculate()` stops the pricing chain. ### 2. Handle Currency Properly Always work in smallest currency units (cents) to avoid floating-point errors: ```typescript // Good const amount = 1999; // $19.99 in cents // Bad const amount = 19.99; // Floating point issues ``` ### 3. Include Metadata Add metadata for debugging and reporting: ```typescript this.result.addItem({ amount: 100, category: 'TAX', meta: { adapter: this.constructor.key, rate: 0.081, }, }); ``` ## Related - [Product Pricing](../extend/pricing/product-pricing.md) - Custom product pricing adapters - [Delivery Pricing](../extend/pricing/delivery-pricing.md) - Shipping fee calculation - [Payment Pricing](../extend/pricing/payment-pricing.md) - Payment fee calculation - [Order Discounts](../extend/pricing/order-discounts.md) - Discount adapters - [Director/Adapter Pattern](./director-adapter-pattern.md) - Plugin architecture --- ## Docker Deployment This guide covers deploying Unchained Engine using Docker containers. ## Dockerfile Create a `Dockerfile` in your project root: ```dockerfile # Build stage FROM node:22-alpine AS builder WORKDIR /app # Copy package files COPY package*.json ./ COPY packages/*/package*.json ./packages/ # Install dependencies RUN npm ci --only=production # Copy source code COPY . . # Build TypeScript RUN npm run build # Production stage FROM node:22-alpine AS production WORKDIR /app # Create non-root user RUN addgroup -g 1001 -S unchained && \ adduser -S unchained -u 1001 # Copy built files COPY --from=builder --chown=unchained:unchained /app/node_modules ./node_modules COPY --from=builder --chown=unchained:unchained /app/lib ./lib COPY --from=builder --chown=unchained:unchained /app/package.json ./ # Set environment ENV NODE_ENV=production ENV PORT=4010 # Switch to non-root user USER unchained # Expose port EXPOSE 4010 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:4010/graphql || exit 1 # Start server CMD ["node", "lib/index.js"] ``` ## Docker Compose For local development or simple deployments: ```yaml # docker-compose.yml version: '3.8' services: engine: build: . ports: - "4010:4010" environment: - NODE_ENV=production - ROOT_URL=http://localhost:4010 - MONGO_URL=mongodb://mongo:27017/unchained - UNCHAINED_TOKEN_SECRET=${UNCHAINED_TOKEN_SECRET} depends_on: - mongo restart: unless-stopped mongo: image: mongo:7 volumes: - mongo_data:/data/db restart: unless-stopped admin-ui: image: unchainedshop/admin-ui:latest ports: - "4011:3000" environment: - UNCHAINED_ENDPOINT=http://engine:4010/graphql depends_on: - engine volumes: mongo_data: ``` ### With Redis and MinIO ```yaml # docker-compose.production.yml version: '3.8' services: engine: build: . ports: - "4010:4010" environment: - NODE_ENV=production - ROOT_URL=https://api.myshop.com - MONGO_URL=mongodb://mongo:27017/unchained - REDIS_URL=redis://redis:6379 - UNCHAINED_TOKEN_SECRET=${UNCHAINED_TOKEN_SECRET} - MINIO_ENDPOINT=minio - MINIO_PORT=9000 - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} - MINIO_BUCKET=unchained-files depends_on: - mongo - redis - minio restart: unless-stopped mongo: image: mongo:7 volumes: - mongo_data:/data/db restart: unless-stopped redis: image: redis:7-alpine volumes: - redis_data:/data restart: unless-stopped minio: image: minio/minio ports: - "9000:9000" - "9001:9001" volumes: - minio_data:/data environment: - MINIO_ROOT_USER=${MINIO_ACCESS_KEY} - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} command: server /data --console-address ":9001" restart: unless-stopped volumes: mongo_data: redis_data: minio_data: ``` ## Building and Running ### Build Image ```bash # Build the image docker build -t my-shop:latest . # Build with build args docker build \ --build-arg NODE_ENV=production \ -t my-shop:latest . ``` ### Run Container ```bash # Run with environment variables docker run -d \ --name my-shop \ -p 4010:4010 \ -e NODE_ENV=production \ -e ROOT_URL=https://api.myshop.com \ -e MONGO_URL=mongodb://... \ -e UNCHAINED_TOKEN_SECRET=your-secret \ my-shop:latest ``` ### Docker Compose Commands ```bash # Start all services docker-compose up -d # View logs docker-compose logs -f engine # Stop all services docker-compose down # Rebuild and restart docker-compose up -d --build ``` ## Kubernetes ### Deployment ```yaml # k8s/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: unchained-engine labels: app: unchained-engine spec: replicas: 2 selector: matchLabels: app: unchained-engine template: metadata: labels: app: unchained-engine spec: containers: - name: engine image: my-shop:latest ports: - containerPort: 4010 envFrom: - secretRef: name: unchained-secrets - configMapRef: name: unchained-config resources: requests: memory: "256Mi" cpu: "200m" limits: memory: "512Mi" cpu: "500m" livenessProbe: httpGet: path: /graphql port: 4010 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /graphql port: 4010 initialDelaySeconds: 5 periodSeconds: 5 ``` ### Service ```yaml # k8s/service.yaml apiVersion: v1 kind: Service metadata: name: unchained-engine spec: selector: app: unchained-engine ports: - protocol: TCP port: 80 targetPort: 4010 type: ClusterIP ``` ### Ingress ```yaml # k8s/ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: unchained-ingress annotations: cert-manager.io/cluster-issuer: letsencrypt-prod spec: tls: - hosts: - api.myshop.com secretName: unchained-tls rules: - host: api.myshop.com http: paths: - path: / pathType: Prefix backend: service: name: unchained-engine port: number: 80 ``` ### ConfigMap and Secrets ```yaml # k8s/configmap.yaml apiVersion: v1 kind: ConfigMap metadata: name: unchained-config data: NODE_ENV: "production" ROOT_URL: "https://api.myshop.com" EMAIL_FROM: "noreply@myshop.com" EMAIL_WEBSITE_NAME: "My Shop" ``` ```yaml # k8s/secrets.yaml apiVersion: v1 kind: Secret metadata: name: unchained-secrets type: Opaque stringData: MONGO_URL: "mongodb+srv://..." UNCHAINED_TOKEN_SECRET: "your-secret-here" STRIPE_SECRET_KEY: "sk_live_..." ``` ## Multi-Stage Builds Optimize your Docker image with multi-stage builds: ```dockerfile # syntax=docker/dockerfile:1 # Dependencies stage FROM node:22-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --only=production && npm cache clean --force # Build stage FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # Production stage FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && \ adduser -S unchained -u 1001 COPY --from=builder --chown=unchained:nodejs /app/lib ./lib COPY --from=deps --chown=unchained:nodejs /app/node_modules ./node_modules COPY --chown=unchained:nodejs package.json ./ USER unchained EXPOSE 4010 CMD ["node", "lib/index.js"] ``` ## Environment Variables Create a `.env` file for Docker Compose: ```bash # .env NODE_ENV=production ROOT_URL=https://api.myshop.com UNCHAINED_TOKEN_SECRET=your-32-character-secret-here MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ``` ## Health Checks ### Simple Health Check ```typescript // src/health.ts const app = express(); app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); app.get('/ready', async (req, res) => { try { // Check database connection await mongoose.connection.db.admin().ping(); res.json({ status: 'ready' }); } catch (error) { res.status(503).json({ status: 'not ready', error: error.message }); } }); ``` ### Docker Health Check ```dockerfile HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD node -e "require('http').get('http://localhost:4010/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" ``` ## Logging Configure logging for containers: ```typescript // Use JSON logging in production const logger = createLogger('app'); // Logs will be JSON formatted logger.info('Server started', { port: 4010 }); ``` ```bash # View container logs docker logs -f my-shop # With timestamps docker logs -f --timestamps my-shop ``` ## Best Practices ### 1. Use Non-Root User ```dockerfile RUN adduser -S unchained USER unchained ``` ### 2. Pin Versions ```dockerfile FROM node:22.0.0-alpine3.19 ``` ### 3. Use .dockerignore ``` # .dockerignore node_modules .git .env *.log tests docs ``` ### 4. Cache Dependencies ```dockerfile # Copy package files first COPY package*.json ./ RUN npm ci # Then copy source (changes don't invalidate npm cache) COPY . . ``` ### 5. Minimize Image Size ```dockerfile FROM node:22-alpine # Alpine is smaller RUN npm ci --only=production # No dev dependencies ``` ## Related Documentation - [Production Checklist](./production-checklist) - Pre-launch checklist - [Environment Variables](../platform-configuration/environment-variables) - Configuration --- ## Deployment This section covers deploying Unchained Engine to production environments. ## Deployment Options | Platform | Complexity | Best For | |----------|------------|----------| | [Railway](../quick-start/run-railway) | Low | Quick setup, managed infrastructure | | [Docker](./docker) | Medium | Custom infrastructure, Kubernetes | | [Manual](./production-checklist) | High | Full control, existing infrastructure | ## Quick Start: Railway The fastest way to deploy Unchained Engine: ```bash # Create project and deploy to Railway npm init @unchainedshop -- --template railway ``` See [Railway Deployment](../quick-start/run-railway) for details. ## Docker Deployment For container-based deployments: ```bash # Build and run with Docker docker build -t my-shop . docker run -p 4010:4010 my-shop ``` See [Docker Deployment](./docker) for details. ## Production Requirements ### Infrastructure - **Node.js 22+** - Runtime environment - **MongoDB 6+** - Primary database - **File Storage** - S3, MinIO, or GridFS for media - **Redis** (optional) - For distributed events and caching ### Environment Variables Essential production variables: ```bash # Required NODE_ENV=production ROOT_URL=https://api.myshop.com MONGO_URL=mongodb://... UNCHAINED_TOKEN_SECRET=your-32-char-secret-minimum # File Storage (when using MinIO plugin) MINIO_ENDPOINT=s3.amazonaws.com MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... MINIO_BUCKET=my-shop-files # Email MAIL_URL=smtp://... EMAIL_FROM=noreply@myshop.com EMAIL_WEBSITE_NAME=My Shop ``` See [Environment Variables](../platform-configuration/environment-variables) for complete list. ## Architecture Recommendations ### Basic Setup ```mermaid flowchart LR S[StorefrontVercel] --> E[EngineRailway] --> D[(MongoDBAtlas)] ``` ### Production Setup ```mermaid flowchart TD CDN[CDNCloudflare] CDN --> Storefront[StorefrontVercel] CDN --> Engine[EngineContainer] CDN --> Admin[Admin UIVercel] Engine --> MongoDB[(MongoDBAtlas)] Engine --> Redis[(RedisEvents)] Engine --> S3[(S3Files)] ``` ## Guides - [Railway Deployment](../quick-start/run-railway) - Deploy with Railway - [Docker Deployment](./docker) - Container deployment - [Production Checklist](./production-checklist) - Pre-launch checklist - [Security](./security) - Security features and compliance - [Environment Variables](../platform-configuration/environment-variables) - Configuration reference --- ## Production Checklist Use this checklist to ensure your Unchained Engine deployment is production-ready. ## Security For comprehensive security documentation, see the [Security Guide](./security). ### Authentication & Secrets - [ ] **Token secret configured** - `UNCHAINED_TOKEN_SECRET` is set to a strong, unique value (minimum 32 characters) - [ ] **Admin credentials secure** - Default admin password changed - [ ] **API tokens rotated** - Any development tokens have been replaced ```bash # Generate secure secrets openssl rand -base64 32 # For UNCHAINED_TOKEN_SECRET ``` ### Cryptographic Standards Unchained uses industry-standard cryptography: - **Password hashing**: PBKDF2-SHA512 with 300,000 iterations - **Token storage**: SHA-256 hashed before database storage - **Session encryption**: AES-256-GCM (optional) ### Network Security - [ ] **HTTPS enforced** - All traffic uses TLS/SSL - [ ] **CORS configured** - Only allowed origins can access the API - [ ] **Rate limiting enabled** - Protection against abuse (implement at reverse proxy) - [ ] **Firewall rules** - Only necessary ports are open ```typescript // Example CORS configuration await startPlatform({ options: { cors: { origin: ['https://myshop.com', 'https://admin.myshop.com'], credentials: true, }, }, }); ``` ### Audit Logging - [ ] **Audit logging enabled** - OCSF-compliant audit logging configured - [ ] **Log storage configured** - Audit logs persisted to file or SIEM - [ ] **Integrity verification** - Hash chain verification scheduled ```typescript const auditLog = createAuditLog({ directory: process.env.UNCHAINED_AUDIT_DIR || './audit-logs', collectorUrl: process.env.UNCHAINED_AUDIT_COLLECTOR_URL, }); configureAuditIntegration(auditLog); ``` ### Database Security - [ ] **MongoDB authentication** - Database requires authentication - [ ] **Network isolation** - Database not publicly accessible - [ ] **Encrypted connections** - MongoDB connection uses TLS - [ ] **Regular backups** - Automated backup schedule configured ## Performance ### Database - [ ] **Indexes created** - All necessary indexes exist - [ ] **Connection pooling** - Pool size appropriate for workload - [ ] **Query optimization** - No slow queries in production ```bash # Check MongoDB indexes mongosh --eval "db.products.getIndexes()" ``` ### Caching - [ ] **Redis configured** (if using) - For events and caching - [ ] **CDN configured** - Static assets served from CDN - [ ] **Browser caching** - Appropriate cache headers set ### Application - [ ] **Production mode** - `NODE_ENV=production` - [ ] **Memory limits** - Container/process memory limits set - [ ] **Health checks** - Liveness and readiness probes configured ## Infrastructure ### Compute - [ ] **Sufficient resources** - CPU and memory for expected load - [ ] **Auto-scaling** - Scales based on demand (if applicable) - [ ] **Multiple replicas** - No single point of failure ### Storage - [ ] **File storage configured** - S3, MinIO, or GridFS - [ ] **Signed URLs** - Secure file access - [ ] **Backup strategy** - Files are backed up ### Monitoring - [ ] **Logging configured** - Centralized log collection - [ ] **Error tracking** - Sentry or similar configured - [ ] **Metrics collection** - Performance metrics tracked - [ ] **Alerting** - Notifications for critical issues ```bash # Logging configuration LOG_LEVEL=info LOG_FORMAT=json # For structured logging ``` ## Configuration ### Environment Variables - [ ] **All required variables set** - See [Environment Variables](../platform-configuration/environment-variables) - [ ] **No hardcoded secrets** - All secrets from environment - [ ] **Separate environments** - Different configs for staging/production ### Essential Variables ```bash # Required NODE_ENV=production ROOT_URL=https://api.myshop.com MONGO_URL=mongodb+srv://... UNCHAINED_TOKEN_SECRET=<32+ character secret> # Email MAIL_URL=smtp://... EMAIL_FROM=noreply@myshop.com EMAIL_WEBSITE_NAME=My Shop EMAIL_WEBSITE_URL=https://myshop.com # File Storage (when using MinIO plugin) MINIO_ENDPOINT=s3.amazonaws.com MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... MINIO_BUCKET=my-shop-files ``` ### Payment Providers - [ ] **Production API keys** - Not using test/sandbox keys - [ ] **Webhooks configured** - Payment webhooks point to production - [ ] **Webhook secrets set** - Webhook signatures are validated ```bash # Stripe production STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... ``` ## Data ### Initial Data - [ ] **Countries configured** - Active countries set up - [ ] **Currencies configured** - Active currencies set up - [ ] **Languages configured** - Active languages set up - [ ] **Tax rates configured** - Correct tax rates for regions ### Products & Content - [ ] **Products published** - All products have correct status - [ ] **Prices set** - Products have prices in all currencies - [ ] **Media uploaded** - Product images are uploaded - [ ] **Translations complete** - Content in all languages ### Providers - [ ] **Payment providers active** - At least one payment method - [ ] **Delivery providers active** - At least one delivery method - [ ] **Provider configuration** - All providers properly configured ## Email ### Configuration - [ ] **SMTP configured** - `MAIL_URL` set correctly - [ ] **From address set** - `EMAIL_FROM` configured - [ ] **Templates customized** - Email templates match brand ### Testing - [ ] **Order confirmation** - Email sends correctly - [ ] **Password reset** - Reset flow works - [ ] **Email preview disabled** - Not using built-in preview in production ```bash # Disable email preview in production EMAIL_PREVIEW=false # or just don't set it ``` ## Testing ### Functional Testing - [ ] **Checkout flow** - Complete purchase works - [ ] **Payment processing** - Real payments process correctly - [ ] **Order management** - Orders can be managed in Admin UI - [ ] **User registration** - Users can create accounts ### Load Testing - [ ] **Performance baseline** - Know expected response times - [ ] **Load tested** - System handles expected traffic - [ ] **Stress tested** - Know system limits ### Error Handling - [ ] **Error pages** - Custom error pages configured - [ ] **Graceful degradation** - Handles partial failures - [ ] **Error logging** - Errors are captured and reported ## Deployment Process ### CI/CD - [ ] **Automated deployments** - Code deploys automatically - [ ] **Testing pipeline** - Tests run before deployment - [ ] **Rollback plan** - Can quickly revert if needed ### Database Migrations - [ ] **Migrations tested** - Run on staging first - [ ] **Backup before migration** - Database backed up - [ ] **Rollback plan** - Can reverse migrations ## Documentation ### Internal - [ ] **Deployment documented** - How to deploy - [ ] **Configuration documented** - All config options - [ ] **Runbooks** - How to handle common issues ### External - [ ] **API documentation** - GraphQL schema documented - [ ] **Integration guides** - For partners/developers ## Launch ### Pre-Launch - [ ] **Staging tested** - Full test on staging environment - [ ] **DNS configured** - Domains point to production - [ ] **SSL certificates** - Valid certificates installed - [ ] **Monitoring active** - All monitoring in place ### Launch Day - [ ] **Team available** - Support team ready - [ ] **Monitoring dashboard** - Real-time visibility - [ ] **Communication plan** - How to communicate issues ### Post-Launch - [ ] **Monitor closely** - Watch for issues first 24-48 hours - [ ] **Gather feedback** - Note any issues for improvement - [ ] **Document learnings** - Update runbooks ## Quick Verification Commands ```bash # Check Node.js version node --version # Should be 22+ # Test MongoDB connection mongosh "$MONGO_URL" --eval "db.adminCommand('ping')" # Test SMTP npm run test:email # If you have this script # Check environment variables env | grep UNCHAINED # Test API endpoint curl https://api.myshop.com/graphql \ -H "Content-Type: application/json" \ -d '{"query":"{ __typename }"}' ``` ## Related Documentation - [Security Guide](./security) - Security features and compliance - [Environment Variables](../platform-configuration/environment-variables) - Full configuration reference - [Docker Deployment](./docker) - Container deployment - [Audit Logging](../extend/events#audit-logging-ocsf) - OCSF audit logging --- ## Security Unchained Engine implements security best practices for e-commerce applications, supporting compliance with PCI DSS, ISO 27001, SOC 2, and other standards. :::info Full Security Documentation For detailed security documentation including compliance matrices, FIPS 140-3 configuration, and deployment recommendations, see the [SECURITY.md](https://github.com/unchainedshop/unchained/blob/master/SECURITY.md) file in the repository. ::: ## Compliance Support | Standard | Support Level | What This Means | |----------|---------------|-----------------| | **PCI DSS SAQ-A** | Compatible | No card data storage; uses tokenization | | **ISO 27001** | Technical Controls | Access control, audit logging, cryptographic standards | | **FIPS 140-3** | Algorithm Compatible | Uses FIPS-approved algorithms (PBKDF2, SHA-256/512, AES-256-GCM) | | **SOC 2** | Audit Support | Tamper-evident audit logs for evidence collection | | **GDPR** | Technical Measures | Audit logging supports Article 30 requirements | ## Cryptographic Standards ### Password Hashing Unchained uses PBKDF2 with industry-leading parameters: - **Algorithm**: PBKDF2 with SHA-512 - **Iterations**: 300,000 (exceeds OWASP recommendation of 210,000) - **Salt**: 16 bytes, cryptographically random - **Key Length**: 256 bytes - **Implementation**: Web Crypto API (`crypto.subtle`) ### Token Security - **Generation**: `crypto.randomUUID()` (CSPRNG-based) - **Storage**: SHA-256 hashed before database storage - **Expiration**: Time-limited (1 hour for verification tokens) - **Single Use**: Tokens invalidated after use ### Session Encryption - **Algorithm**: AES-256-GCM (authenticated encryption) - **Key Size**: 32 bytes - **Implementation**: kruptein library ## Payment Security (PCI DSS) Unchained is designed for **PCI DSS SAQ-A eligibility**: - Credit card numbers (PAN) are **never stored** - CVV/CVC codes are **never stored** - Only payment provider tokens are stored All payment integrations use tokenization: | Provider | Tokenization Method | |----------|-------------------| | Stripe | PaymentIntent / SetupIntent | | Datatrans | Secure Fields | | Saferpay | Redirect with token | | Braintree | Client SDK tokenization | | PayPal | Order ID reference | ## Access Control ### Role-Based Access Control (RBAC) - **128+ defined actions** covering all API operations - **Built-in roles**: admin, logged-in user, guest - **Ownership validation**: Users can only access their own resources - **Field-level permissions**: GraphQL type resolvers enforce access ```typescript // Example permission check role.allow(actions.updateOrder, async (obj, params, context) => { const order = await modules.orders.findOrder({ orderId: params.orderId }); return order.userId === context.userId; }); ``` ## Audit Logging Unchained provides **OCSF-compliant** (Open Cybersecurity Schema Framework) audit logging with tamper-evident hash chains. ### Features - **OCSF v1.4.0 schema** - Industry-standard format supported by AWS Security Lake, Datadog, Splunk, Google Chronicle - **Tamper-evident** - SHA-256 hash chain for integrity verification - **Append-only** - No update or delete operations - **SIEM-ready** - Direct ingestion into security monitoring tools ### Quick Start ```typescript // Create audit log instance const auditLog = createAuditLog('./audit-logs'); // Enable automatic event capture configureAuditIntegration(auditLog); // Events automatically captured: // - Login/logout // - User creation/deletion // - Password changes // - Role changes // - Order checkout // - Payments ``` See [Audit Logging](../extend/events#audit-logging) for detailed documentation. ## Input Validation ### ReDoS Prevention All user-supplied strings used in regular expressions are escaped: ```typescript // User input is escaped before regex construction const regex = new RegExp(escapeRegexString(userInput), 'i'); ``` ### Timing Attack Prevention Security-sensitive string comparisons use constant-time algorithms: ```typescript // Constant-time comparison for tokens/secrets if (await timingSafeStringEqual(providedToken, expectedToken)) { // Token is valid } ``` ## Session Security ### Cookie Configuration ```typescript // Secure defaults { httpOnly: true, // Prevent XSS access secure: true, // HTTPS only sameSite: 'none', // Configurable maxAge: 604800, // 7 days } ``` ### Environment Variables | Variable | Purpose | Default | |----------|---------|---------| | `UNCHAINED_TOKEN_SECRET` | Session encryption (min 32 chars) | Required | | `UNCHAINED_COOKIE_NAME` | Cookie name | `unchained_token` | | `UNCHAINED_COOKIE_DOMAIN` | Cookie domain restriction | - | | `UNCHAINED_COOKIE_SAMESITE` | SameSite attribute | `none` | | `UNCHAINED_COOKIE_INSECURE` | Disable secure flag | `false` | ## Error Handling Errors are designed to prevent information leakage: - **Authentication errors**: Generic "Invalid credentials" message - **Token errors**: "Token invalid or expired" (doesn't distinguish) - **Permission errors**: "Not authorized" (no action details) - **User enumeration prevention**: Password reset returns success regardless of user existence ## Rate Limiting Rate limiting should be implemented at the **reverse proxy level** (nginx, Cloudflare, AWS ALB): ```nginx # nginx example limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; server { location /graphql { limit_req zone=api burst=50 nodelay; proxy_pass http://unchained:4000; } } ``` ### Recommended Limits | Endpoint | Recommended Limit | Rationale | |----------|------------------|-----------| | Login mutations | 5/minute per IP | Prevent brute force | | Password reset | 3/hour per IP | Prevent enumeration | | Registration | 10/hour per IP | Prevent spam | | GraphQL queries | 100/second per IP | General protection | ## FIPS 140-3 Mode For environments requiring FIPS compliance, use a FIPS-validated runtime: ```dockerfile # Using Chainguard FIPS image FROM cgr.dev/chainguard/node-fips:latest WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . CMD ["node", "index.js"] ``` See [SECURITY.md](https://github.com/unchainedshop/unchained/blob/master/SECURITY.md#fips-140-3-compatibility) for detailed FIPS configuration. ## Reporting Vulnerabilities If you discover a security vulnerability: - **Email**: hello@unchained.shop - **Do NOT** open public GitHub issues for security vulnerabilities We will acknowledge receipt within 48 hours and provide a detailed response within 7 days. ## Related - [Production Checklist](./production-checklist) - Pre-launch security checklist - [Authentication](../concepts/authentication) - Authentication patterns - [Audit Logging](../extend/events#audit-logging) - Detailed audit logging docs - [SECURITY.md](https://github.com/unchainedshop/unchained/blob/master/SECURITY.md) - Full security documentation --- ## Admin UI Extension Admin UI out of the box supports most of the administration tasks a user wants to perform. However, unchained is flexible and can be extended to support any data or data structure by [Extending the schema](./graphql). While this is great, but it means the data will not be accessible through admin UI. In order to view the additional information in your unchained engine you need to provide admin UI a configuration object that contains fragments of the additional types and inject to the unchained platform on start up. the configuration object `AdminUiConfig` has a field called `customProperties` that is an array of `AdminUiConfigCustomEntityInterface` objects where `AdminUiConfigCustomEntityInterface` type has two fields: `entityName`, which is a non-nullable string, and `inlineFragment`, which is also a non-nullable string that holds the fragment definition. ```js const config = { customProperties: [ { entityName: 'User', inlineFragment: `...on User { _id avatar { _id name size type url } }` } ] } ``` After we defined all the additional fragments along with their type, we simply pass them to unchained platform like shown below. ``` await startPlatform({ ..., adminUiConfig: config, ... }) ``` Note that `entityName` should be an Entity supported by Unchained. That's it, now the new custom entity data will be visible (Read-only) in Admin UI. --- ## Filter(Catalog) # Custom Filter Plugins Filter plugins are useful when you want to have a tailored filter functionality based on some requirements. you can have more than one FilterAdapter implementations and all of them will be executed sequentially based on there `orderIndex` index. Filter adapter with lower `orderIndex` will be first on the execution order and any modifications made on the previous Filter adapter will be available to filter that are executed after it. This is useful when you want to modularize your business logic. When creating a filter make sure you don't use the same key for different filters. To implement a custom filter plugin you need to implement `IFilterAdapter` and register it on the `FilterDirector` Below is a simple filter plugin that will filter products based on there attribute values. ```typescript const ShopAttributeFilter: IFilterAdapter = { key: 'ch.shop.filter', label: 'Filters products by metadata attributes', version: '1.0.0', orderIndex: 10, actions: (params: FilterContext & Context): FilterAdapterActions => { return { aggregateProductIds(params: { productIds: Array }) { return productIds; }, searchAssortments( params: { assortmentIds: Array; }, options?: { filterSelector: Filter; assortmentSelector: Filter; sortStage: FindOptions['sort']; }, ) { return assortmentIds; }, searchProducts( params: { productIds: Array; }, options?: { filterSelector: Query; productSelector: Query; sortStage: FindOptions['sort']; }, ) { return productIds; }, transformProductSelector( query: Query, options?: { key?: string; value?: any }, ) { return {...query, inStock: true}; }, async transformSortStage( sort: FindOptions['sort'], options?: { key: string; value?: any }, ) { return {...sort, created: -1 }; }, async transformFilterSelector( query: Query, options?: any, ): Promise { if (!last || Object.keys(last).length === 0) { return null; } return last; }, async transformProductSelector( query: Query, options?: { key?: string; value?: any }, ): Promise { if (!key) return last; return { status: 'ACTIVE', 'shop.attributes': { $elemMatch: { key, value: value !== undefined ? value : { $exists: true }, }, }, }; }, }; }, }; ``` ### Breakdown Lets look into each `field` & `function` defined by `IFilterAdapter` and what the function of each. - `key`: Unique identification of adapter, identical to ID. - `label`: Human readable label of the filter. - `version` - `orderIndex`: defines the execution order of a particular filter adapter. filter adapters lower value will be executed first - `transformProductSelector`: modifies selectors that are going to be used to filter products list. it gets filters that are added by filter adapters with lowe `orderIndex` as it's argument and is expected to return valid mongodb selector expression. In the above example we are adding `status: 'ACTIVE'` selector if there is any filter configuration key provided on the filter, also expect the attribute value to match the configuration key: value. Note: filters with higher `orderIndex` will get this value as there argument default value: `{ status: { '$in': [ 'ACTIVE', null ] } }` - `transformFilterSelector`: This transform fn allows you to customize the selector that returns the filters that should show up for a given search query. By default it uses the assortment's filter links to return those filters. Sometimes there is no assortment scope (global search for products) or you want to make a specific filter appear just everywhere. In those cases this can be helpful by returning additional filters through the selector. - `transformSortStage`: Used to modify sort options that will to be applied for filter. default sort option value is `{ index: 1 }` which is for mongodb automatically assigned index value, but it can be changed to use any field in a collection. in the example above we are adding a sort `{ created: -1 }` to the previous sort option and filter adapters with higher `orderIndex` will get this value as there parameter. default value: `{ _id: { '$in': [] }, isActive: true }` - `searchAssortments`: Triggered when searching for assortments, It is required to return array of assortment Ids that pass the filter checks or empty array if none is found. it gets `assortmentIds` that have been matched so far by filters with lower `orderIndex`. - `searchProducts`: Triggered when searching for products, It is required to return array of product Ids that pass the filter checks or empty array if none is found. it gets `productIds` that have been matched so far by filters with lower `orderIndex`. - `aggregateProductIds`: Executed when searching products, it holds array of the final matching productIds found so far. It is required to return array of product ids. ### Order of execution 1. `transformFilterSelector` 2. `transformProductSelector` if current operation is search product, else, skip to step 3 3. `transformSortStage` 4. `searchProducts` or `searchAssortments` depending on the operation. i.e when searching for products or searching for assortments. 5. `aggregateProductIds` if current operation is search product ### Final Step In order to make use of the filter we need to register it on the FilterDirector. ```typescript ... FilterDirector.registerAdapter(ShopAttributeFilter); ``` ### Shorthand Incase you only want to change implementation of only few functions and keep the other default implementation, you can do so by importing `FilterAdapter` from `@unchainedshop/core-filters` and override the that specific functions implementation. Below is a simplified implementation of the `ShopAttributeFilter` above, this time it will use the default implantation and override `transformProductSelector` function only. ```typescript const ShopAttributeFilter: IFilterAdapter = { ...FilterAdapter, key: 'ch.shop.filter', label: 'Filters products by metadata attributes', version: '1.0.0', orderIndex: 10, actions: (params: FilterContext & Context): FilterAdapterActions => { return { ...FilterAdapter.actions(params), async transformProductSelector(query, options) { if (!key) return last; return { status: 'ACTIVE', 'shop.attributes': { $elemMatch: { key, value: value !== undefined ? value : { $exists: true }, }, }, }; }, } } } FilterDirector.registerAdapter(ShopAttributeFilter); ``` --- ## Search Behavior Unchained allows you to search and filter products and assortments. ### Products Search There are two ways to search for products in Unchained: 1. **General Search**: Search all products in your system. ```graphql query searchProducts($queryString: String, $limit: Int) { searchProducts(queryString: $queryString, includeInactive: true) { products { _id status texts { _id title description } media(limit: $limit) { texts { _id title } file { _id url name } } } } } ``` Options include `orderBy`, `includeInactive`, and `assortmentId`. 2. **Assortment Search**: Search products attached to a specific assortment. ```graphql query Assortment($assortmentId: ID) { assortment(assortmentId: $assortmentId) { searchProducts { filteredProductsCount filters { filteredProductsCount isSelected options { filteredProductsCount isSelected } } products { _id } } } } ``` ### Assortments Search Similar to the general product search, you can search for assortments: ```graphql query searchAssortments($queryString: String) { searchAssortments(queryString: $queryString, includeInactive: true) { assortments { _id isActive texts { _id title description } } } } ``` --- ## Write Custom Modules # Custom Modules There might be cases where the out of the box functionalities are not enough to solve a particular problem. Custom Modules enables the developer to add additional functionality to the core engine. A module typically accesses the MongoDB to read and write data from and to the database but it could also provide an interface to some external API that needs to be called from plugins or custom GraphQL resolvers. In many cases this goes together with [extending the API](./graphql) to include additional mutations and queries that access the module's functions. Below is an example of a custom module that will be used to change currency of a cart. ```typescript const myModule = { configure: async ({ db }: ModuleInput>) => { const Orders = await OrdersCollection(db) return { async changeCartCurrency(currency: string, cartId: string) { const selector = generateDbFilterById(cartId) Orders.updateOne(selector, { $set: { currencyCode, context: { currency }, }, }) return Orders.findOne({ _id: cartId }) }, } }, } ``` Let's go through the code line by line 1. Imported the modules and utility functions we want to use in the module (`OrdersCollection` & `generateDbFilterById`) 2. Added type for our custom module. in this case our module only contains single function `changeCartCurrency` 3. Defined the actual module by creating object with `configure` function as it's only key. returns an object with key-value pairs that match the module type definition. Since our custom module has only one property configure function should return an object with the exact property mapping. After defining the custom module the final step is registering it to the platform and making it globally available for use just like the built in modules. ``` startPlatform({ ... modules: { ... myModule ... }, ... }) ``` **Note: avoid giving the custom module a name that is identical to the built in module. this will replace the existing module and change result in runtime error** Now the `myModule` is available globally though out the engine context and can be accessed as follows ``` unchainedContext.modules.myModule.changeCartCurrency(...) ``` Read more about unchained context and how to access it in **Accessing Unchained Context** #### Custom Service Services allow you to add utility functions that can be used throughout the engine context in a similar fashion to modules. The difference between a service and a module usually is that a module doesn't have direct DB access but composes multiple module calls through the unchained context. You can access built in or custom services from unchained context anywhere in the application like so: ```typescript unchainedAPIContext.services.serviceName.[function name] ``` It is possible to create a custom service for your need and have it available throughout the engine context like the built-in services. Custom services function are bound to the core modules and have access to those through this. ```typescript function serviceFunc(this: Modules, ...myParams: any) { ... this.orders.findOrder(...) } ``` --- ## Enrollments # Enrollment Adapters Enrollment adapters handle subscription-based products and recurring billing. They control how subscriptions are created, when orders are generated, and whether access should be granted. ## EnrollmentAdapter For every subscription product (PLAN_PRODUCT), an enrollment adapter processes the subscription lifecycle. To handle subscriptions, create an enrollment adapter that implements the `IEnrollmentAdapter` interface and register it with the EnrollmentDirector. Multiple enrollment adapters can be registered. The first adapter where `isActivatedFor` returns `true` for the product's plan configuration will be used. ## Adapter Interface ```typescript EnrollmentDirector, EnrollmentAdapter, type IEnrollmentAdapter } from '@unchainedshop/core'; const CustomEnrollmentAdapter: IEnrollmentAdapter = { ...EnrollmentAdapter, key: 'my-shop.enrollments.custom', version: '1.0.0', label: 'Custom Subscription Handler', isActivatedFor: (productPlan) => { // Activate for specific usage calculation types return productPlan?.usageCalculationType === 'METERED'; }, transformOrderItemToEnrollmentPlan: async (orderPosition, unchainedAPI) => { // Transform order item into enrollment configuration return { configuration: orderPosition.configuration, productId: orderPosition.productId, quantity: orderPosition.quantity, }; }, actions: (params) => { const { enrollment, product } = params; return { ...EnrollmentAdapter.actions(params), isValidForActivation: async () => { // Check if the subscription should grant access const periods = enrollment?.periods || []; const now = Date.now(); return periods.some(period => { const start = new Date(period.start).getTime(); const end = new Date(period.end).getTime(); return start <= now && end >= now; }); }, isOverdue: async () => { // Check if payment is overdue return false; }, nextPeriod: async () => { // Calculate the next billing period // Returns null if no more periods should be created const plan = product?.plan; if (!plan) return null; const lastPeriod = enrollment?.periods?.[enrollment.periods.length - 1]; const startDate = lastPeriod ? new Date(lastPeriod.end) : new Date(); return { start: startDate, end: addDays(startDate, 30), // 30-day period isTrial: false, }; }, configurationForOrder: async ({ period }) => { // Generate order configuration for a billing period // Return null to skip order generation if (!enrollment) throw new Error('Enrollment missing'); const beginningOfPeriod = period.start.getTime() <= Date.now(); if (!beginningOfPeriod) return null; return { period, orderContext: { // Additional context passed to the order }, orderPositionTemplates: [{ quantity: enrollment.quantity || 1, productId: enrollment.productId, originalProductId: enrollment.productId, configuration: enrollment.configuration, }], }; }, }; }, }; ``` ## Method Reference ### Static Methods - **isActivatedFor(productPlan)**: Determines if this adapter handles a specific product plan. Check `usageCalculationType` or other plan properties. - **transformOrderItemToEnrollmentPlan(orderPosition, unchainedAPI)**: Transforms an order item into enrollment data when a subscription is first created from a purchase. ### Action Methods - **isValidForActivation()**: Returns `true` if the subscription should currently grant access. Typically checks if the current date falls within an active period. - **isOverdue()**: Returns `true` if payment is overdue. Used to trigger dunning workflows or suspend access. - **nextPeriod()**: Calculates the next billing period. Returns `null` to indicate no more periods should be created (subscription ended). - **configurationForOrder(\{ period \})**: Generates the order configuration for a billing period. Return `null` to skip order generation for this period. ## Usage Calculation Types Product plans can have different `usageCalculationType` values: | Type | Description | |------|-------------| | `LICENSED` | Period-based access (e.g., monthly subscription) | | `METERED` | Usage-based billing (e.g., API calls, storage) | ## Example: Metered Subscription ```typescript EnrollmentDirector, EnrollmentAdapter, type IEnrollmentAdapter } from '@unchainedshop/core'; const MeteredEnrollmentAdapter: IEnrollmentAdapter = { ...EnrollmentAdapter, key: 'my-shop.enrollments.metered', version: '1.0.0', label: 'Metered Usage Subscription', isActivatedFor: (productPlan) => { return productPlan?.usageCalculationType === 'METERED'; }, actions: (params) => { const { enrollment, product } = params; return { ...EnrollmentAdapter.actions(params), isValidForActivation: async () => { // Always active as long as enrollment exists return enrollment?.status === 'ACTIVE'; }, configurationForOrder: async ({ period }) => { if (!enrollment) throw new Error('Enrollment missing'); // Calculate usage for the period const usage = await calculateUsageForPeriod( enrollment._id, period.start, period.end ); if (usage.units === 0) return null; // No usage, no order return { period, orderContext: { usageUnits: usage.units, usageDetails: usage.details, }, orderPositionTemplates: [{ quantity: usage.units, productId: enrollment.productId, originalProductId: enrollment.productId, configuration: [ { key: 'usageUnits', value: String(usage.units) }, { key: 'periodStart', value: period.start.toISOString() }, { key: 'periodEnd', value: period.end.toISOString() }, ], }], }; }, }; }, }; EnrollmentDirector.registerAdapter(MeteredEnrollmentAdapter); ``` ## Example: Trial with Grace Period ```typescript EnrollmentDirector, EnrollmentAdapter, type IEnrollmentAdapter } from '@unchainedshop/core'; const TrialEnrollmentAdapter: IEnrollmentAdapter = { ...EnrollmentAdapter, key: 'my-shop.enrollments.trial', version: '1.0.0', label: 'Trial Subscription with Grace Period', isActivatedFor: (productPlan) => { return productPlan?.usageCalculationType === 'LICENSED' && productPlan?.trialIntervalCount > 0; }, actions: (params) => { const { enrollment, product, modules } = params; const GRACE_PERIOD_DAYS = 7; return { ...EnrollmentAdapter.actions(params), isValidForActivation: async () => { const periods = enrollment?.periods || []; const now = Date.now(); // Check if within any period (including grace period for non-trial) return periods.some(period => { const start = new Date(period.start).getTime(); let end = new Date(period.end).getTime(); // Add grace period for paid periods if (!period.isTrial) { end += GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000; } return start <= now && end >= now; }); }, isOverdue: async () => { const periods = enrollment?.periods || []; const currentPeriod = periods.find(p => !p.isTrial && p.orderId); if (!currentPeriod?.orderId) return false; const order = await modules.orders.findOrder({ orderId: currentPeriod.orderId, }); if (order?.status !== 'PENDING') return false; const dueDate = new Date(currentPeriod.start); dueDate.setDate(dueDate.getDate() + GRACE_PERIOD_DAYS); return Date.now() > dueDate.getTime(); }, }; }, }; EnrollmentDirector.registerAdapter(TrialEnrollmentAdapter); ``` ## Registering an Enrollment Adapter ```typescript EnrollmentDirector.registerAdapter(CustomEnrollmentAdapter); ``` ## Related - [Enrollment Plugins](../plugins/enrollments/) - Built-in enrollment adapters - [Licensed Enrollments Plugin](../plugins/enrollments/enrollment-licensed.md) - Default implementation - [Enrollment Order Generator Worker](../plugins/workers/worker-enrollment-order-generator.md) - Automatic order generation --- ## Event System Unchained uses a publish-subscribe (pub/sub) event model to track events emitted by each module. By default it uses Node.js EventEmitter, but can be extended to connect to distributed event queues like Redis. ## Core API The `@unchainedshop/events` module exports utility functions for event handling: ```typescript // Register custom events registerEvents(['MY_CUSTOM_EVENT']); // Subscribe to events subscribe('ORDER_CHECKOUT', ({ payload }) => { console.log('Order checked out:', payload.order._id); }); // Emit events emit('MY_CUSTOM_EVENT', { data: 'value' }); // Get all registered event names const allEvents = getRegisteredEvents(); ``` ### Event Names Events are registered as strings. You can query available events via GraphQL: ```graphql query { events { _id type } } ``` Or use `getRegisteredEvents()` at runtime to get the list of registered events. ## Built-in Events Each module emits events for tracking and integration. See the module documentation for the complete list of events: | Module | Events Documentation | |--------|---------------------| | **Assortments** | [Assortments Module](../platform-configuration/modules/assortments) | | **Bookmarks** | [Bookmarks Module](../platform-configuration/modules/bookmarks) | | **Countries** | [Countries Module](../platform-configuration/modules/countries) | | **Currencies** | [Currencies Module](../platform-configuration/modules/currencies) | | **Delivery** | [Delivery Module](../platform-configuration/modules/delivery) | | **Enrollments** | [Enrollments Module](../platform-configuration/modules/enrollments) | | **Events** | [Events Module](../platform-configuration/modules/events) | | **Files** | [Files Module](../platform-configuration/modules/files) | | **Filters** | [Filters Module](../platform-configuration/modules/filters) | | **Languages** | [Languages Module](../platform-configuration/modules/languages) | | **Orders** | [Orders Module](../platform-configuration/modules/orders) | | **Payment** | [Payment Module](../platform-configuration/modules/payment) | | **Products** | [Products Module](../platform-configuration/modules/products) | | **Quotations** | [Quotations Module](../platform-configuration/modules/quotations) | | **Users** | [Users Module](../platform-configuration/modules/users) | | **Warehousing** | [Warehousing Module](../platform-configuration/modules/warehousing) | | **Worker** | [Worker Module](../platform-configuration/modules/worker) | ## Subscribing to Events ```typescript // Track order confirmations subscribe('ORDER_CONFIRMED', async ({ payload }) => { const { order } = payload; // Send to analytics await analytics.track('purchase', { orderId: order._id, total: order.total, }); }); // Track product views subscribe('PRODUCT_VIEW', async ({ payload }) => { await analytics.track('product_view', { productId: payload.productId, }); }); ``` ## Custom Events Register and emit your own events: ```typescript // Register at boot time registerEvents([ 'INVENTORY_LOW', 'CUSTOMER_TIER_CHANGED', 'FRAUD_DETECTED', ]); // Subscribe to custom event subscribe('INVENTORY_LOW', async ({ payload }) => { await notifyWarehouse(payload.productId, payload.currentStock); }); // Emit from your code emit('INVENTORY_LOW', { productId: 'product-123', currentStock: 5, threshold: 10, }); ``` ## Custom Event Adapter Replace the default EventEmitter with a distributed queue like Redis: ```typescript const { REDIS_PORT = 6379, REDIS_HOST = '127.0.0.1' } = process.env; const subscribedEvents = new Set(); const RedisEventEmitter = (): EmitAdapter => { const redisPublisher = createClient({ url: `redis://${REDIS_HOST}:${REDIS_PORT}`, }); const redisSubscriber = createClient({ url: `redis://${REDIS_HOST}:${REDIS_PORT}`, }); return { publish: (eventName, payload) => { redisPublisher.publish(eventName, JSON.stringify(payload)); }, subscribe: (eventName, callback) => { if (!subscribedEvents.has(eventName)) { redisSubscriber.subscribe(eventName, (payload) => { callback(JSON.parse(payload)); }); subscribedEvents.add(eventName); } }, }; }; // Set the adapter before starting the platform setEmitAdapter(RedisEventEmitter()); ``` ## Use Cases ### Analytics Integration ```typescript subscribe('ORDER_CHECKOUT', async ({ payload }) => { await gtag('event', 'purchase', { transaction_id: payload.order._id, value: payload.order.total / 100, currency: payload.order.currency, }); }); ``` ### Webhook Triggers ```typescript subscribe('ORDER_CONFIRMED', async ({ payload }) => { await fetch('https://your-webhook.com/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload.order), }); }); ``` ### Inventory Alerts ```typescript subscribe('ORDER_ADD_PRODUCT', async ({ payload, context }) => { const product = await context.modules.products.findProduct({ productId: payload.orderPosition.productId, }); if (product.stock < 10) { emit('INVENTORY_LOW', { productId: product._id, currentStock: product.stock, }); } }); ``` ### Audit Logging Unchained provides built-in OCSF-compliant audit logging. See the [dedicated Audit Logging section](#audit-logging-ocsf) below for the recommended approach. For simple custom audit logging: ```typescript const auditEvents = [ 'ORDER_CHECKOUT', 'USER_CREATE', 'PRODUCT_UPDATE', 'PAYMENT_PROVIDER_CREATE', ]; auditEvents.forEach(eventName => { subscribe(eventName, async ({ payload }) => { await db.auditLog.insertOne({ event: eventName, payload, timestamp: new Date(), }); }); }); ``` ## Querying Registered Events Use GraphQL to list all registered events: ```graphql query { events { _id type } } ``` ## Audit Logging (OCSF) Unchained provides enterprise-grade audit logging based on the **OCSF (Open Cybersecurity Schema Framework)** - an industry-standard schema supported by AWS Security Lake, Datadog, Splunk, Google Chronicle, and other SIEM systems. ### Features - **OCSF v1.4.0 compliant** - Industry-standard event schema - **Tamper-evident** - SHA-256 hash chain for integrity verification - **Append-only** - No update or delete operations - **JSON Lines format** - Easy parsing and integration - **SIEM-ready** - Direct ingestion into security monitoring tools - **HTTP push** - Optional push to OpenTelemetry Collector, Fluentd, or Vector ### Quick Start ```typescript // Create audit log instance const auditLog = createAuditLog('./audit-logs'); // Enable automatic event capture for all security-relevant events configureAuditIntegration(auditLog); // Events automatically captured: // - API_LOGIN_TOKEN_CREATED β†’ Authentication (LOGON) // - API_LOGOUT β†’ Authentication (LOGOFF) // - USER_CREATE β†’ Account Change (CREATE) // - USER_REMOVE β†’ Account Change (DELETE) // - USER_UPDATE_PASSWORD β†’ Account Change (PASSWORD_CHANGE) // - USER_ADD_ROLES β†’ Account Change (ATTACH_POLICY) // - ORDER_CREATE β†’ API Activity (CREATE) // - ORDER_CHECKOUT β†’ API Activity (CHECKOUT) // - ORDER_PAY β†’ API Activity (PAYMENT) // - And more... ``` ### Manual Logging For custom audit events: ```typescript createAuditLog, OCSF_AUTH_ACTIVITY, OCSF_ACCOUNT_ACTIVITY, OCSF_API_ACTIVITY, } from '@unchainedshop/events'; const auditLog = createAuditLog('./audit-logs'); // Log authentication event await auditLog.logAuthentication({ activity: OCSF_AUTH_ACTIVITY.LOGON, userId: user._id, userName: user.email, success: true, remoteAddress: req.ip, sessionId: req.sessionID, isMfa: true, }); // Log failed login attempt await auditLog.logAuthentication({ activity: OCSF_AUTH_ACTIVITY.LOGON, success: false, remoteAddress: req.ip, message: 'Invalid password', }); // Log account change (role assignment) await auditLog.logAccountChange({ activity: OCSF_ACCOUNT_ACTIVITY.ATTACH_POLICY, userId: targetUser._id, actorUserId: adminUser._id, success: true, message: 'Admin role assigned', }); // Log API activity (payment) await auditLog.logApiActivity({ activity: OCSF_API_ACTIVITY.PAYMENT, userId: user._id, operation: 'processPayment', success: true, message: 'Payment completed', }); // Log access denied await auditLog.logApiActivity({ activity: OCSF_API_ACTIVITY.ACCESS_DENIED, userId: user._id, success: false, message: 'Insufficient permissions', }); ``` ### HTTP Collector Push Push audit logs to OpenTelemetry Collector, Fluentd, or Vector: ```typescript const auditLog = createAuditLog({ directory: './audit-logs', collectorUrl: 'http://otel-collector:4318/v1/logs', collectorHeaders: { 'Authorization': 'Bearer ', }, batchSize: 10, flushIntervalMs: 5000, }); ``` ### Querying Audit Logs ```typescript // Find failed login attempts const failedLogins = await auditLog.find({ classUids: [OCSF_CLASS.AUTHENTICATION], success: false, startTime: new Date('2024-01-01'), limit: 100, }); // Get failed login count for rate limiting const attempts = await auditLog.getFailedLogins({ remoteAddress: '192.168.1.1', since: new Date(Date.now() - 15 * 60 * 1000), // Last 15 minutes }); // Verify integrity of audit log chain const result = await auditLog.verify(); if (!result.valid) { console.error('Audit log tampering detected:', result.error); } ``` ### OCSF Event Classes | Class | UID | Use Cases | |-------|-----|-----------| | **Authentication** | 3002 | Login, logout, failed login, MFA | | **Account Change** | 3001 | User CRUD, password changes, role changes | | **API Activity** | 6003 | API access, payments, orders, access denied | ### SIEM Integration Audit log files (`audit-YYYY-MM-DD.jsonl`) can be directly ingested by SIEM systems: **Filebeat (Elastic):** ```yaml filebeat.inputs: - type: log paths: - /path/to/audit-logs/*.jsonl json.keys_under_root: true ``` **Promtail (Loki/Grafana):** ```yaml scrape_configs: - job_name: unchained-audit static_configs: - targets: [localhost] labels: job: audit __path__: /path/to/audit-logs/*.jsonl ``` ### Shutdown Always close the audit log on shutdown to flush pending events: ```typescript process.on('SIGTERM', async () => { await auditLog.close(); process.exit(0); }); ``` ## Related - [Security](../deployment/security) - Security features and compliance - [Events Module](../platform-configuration/modules/events) - Module configuration - [Worker](./worker) - Background job processing --- ## Extend the GraphQL API We know that any two projects don't have the same business logic and data model. Projects need different data models to hold data for of there domain so for this reason unchained is build to be easily extended to hold custom data a project might need. Most mutations of unchained accept a JSON type `meta` property for this purpose. you can pass this field and object holding custom properties you want to store related to a certain entity/object. In this we will extend `product` to hold two custom properties `size` and `expiryDate` for demonstration purposes. In order to extend the schema, all we need to do is - Extend the entity in question to include the custom fields - Add a resolver function to resolve the field from the meta field of the entity. - Register the type and resolver definitions in unchained passing them to the `startPlatform` function at boot time. Let's follow the above guide to extend the `Product` entity. **Extend entity include the custom fields** ```js const typeDefs = [ /* GraphQL */ ` extend type Product { size: String, expiryDate: String } `, ]; ``` **Add a resolver function to resolve the fields** ```js const resolverDefs = { SimpleProduct: { size({ meta = {} }) { return meta?.size }, expiryDate({ meta = {} }) { return meta?.expiryDate }, }, PlanProduct: { size({ meta = {} }) { return meta?.size }, expiryDate({ meta = {} }) { return meta?.expiryDate }, }, ConfigurableProduct: { size({ meta = {} }) { return meta?.size }, expiryDate({ meta = {} }) { return meta?.expiryDate }, }, } ``` **Register the type and resolver definition** ```js await startPlatform({ typeDefs: [...typeDefs], resolvers: [resolvers], }) seed() ``` That was all, everything is setup and the schema will be updated to include the custom types defined above for product entity. Assuming we have a `SimulatedProduct` product type with a `productId` `test-product-id`, we can use the `mutation.updateProduct` to assign values for the new fields. ```graphql mutation UpdateProductMeta { updateProduct( productId: "test-product-id" product: { meta: { size: "large", expiryDate: "1694908800000" } } ) { _id } } ``` Note: The `size` and `expiryDate` fields shown above are custom fields added via resolvers and would need to be queried after the type extensions are registered. This will return with the updated value: ```json { "_id": "test-product-id", "size": "large", "expiryDate": "2023-09-17" } ``` ### Adjust Sort Options for the default Sorting algoritm To support sorting other than the default order index, extend available sort codes: ``` extend enum SearchOrderBy { meta_priceRanges_minSimulatedPrice_DESC meta_priceRanges_minSimulatedPrice_ASC } ``` Explanation: DESC at the end means it should sort descending whereas ASC or neither direction means it will sort ascending. Underscores will be replaced by dots before firing to the MongoDB, so "meta_priceRanges_minSimulatedPrice_DESC" this effectively translates to: ``` { $sort: { "meta.priceRanges.minSimulatedPrice": -1, "_id": 1 } } ``` ### List of entity types that hold meta property ```js Assortment AssortmentProduct AssortmentLink AssortmentFilter Media Product ProductReview ProductReviewVote ConfigurableProduct SimpleProduct BundleProduct PlanProduct Enrollment Quotation EnrollmentPayment EnrollmentDelivery User ``` Note: While every entity that listed above exposes a meta property there is an exception for Order related entities. Order related entities store `meta` property and other useful information about the order under `context` property. So in order to get the `meta` value of an order you read it from the `context`. This entity types are listed below: ``` Order OrderDelivery OrderDeliveryPickUp OrderDeliveryShipping OrderPaymentInvoice OrderPaymentGeneric OrderPayment ``` ```js const resolverDefs = { OrderDelivery: { isBatteryPart(obj) { return obj.context?.["isBatteryPart"] || false; }, ``` For detail reference about graphql schema and how to extend the refer to the official [graphql documentation](https://graphql.org/learn/schema/) ## Using Pothos GraphQL If you prefer a code-first approach to GraphQL, you can use [Pothos](https://pothos-graphql.dev/) with Unchained. This allows you to define your schema using TypeScript instead of SDL. ### Setup ```typescript // Build the Unchained schema const unchainedSchema = makeExecutableSchema({ typeDefs: buildDefaultTypeDefs({ actions: Object.keys(roles.actions), }), resolvers: [unchainedResolvers], }); // Create Pothos builder const builder = new SchemaBuilder({}); // Define your custom types builder.queryType({ fields: (t) => ({ hello: t.string({ args: { name: t.arg.string(), }, resolve: (parent, { name }, unchainedContext) => { return `Hello, ${name || 'World'}!`; }, }), }), }); // Merge schemas const schema = mergeSchemas({ schemas: [unchainedSchema, builder.toSchema()], }); // Start with merged schema const engine = await startPlatform({ schema, }); ``` ### Adding Custom Types ```typescript const builder = new SchemaBuilder({}); // Define a custom type builder.objectType('CustomProduct', { fields: (t) => ({ id: t.exposeID('id'), name: t.exposeString('name'), customField: t.string({ resolve: (parent) => `Custom: ${parent.name}`, }), }), }); // Add query for custom type builder.queryType({ fields: (t) => ({ customProducts: t.field({ type: ['CustomProduct'], resolve: async (parent, args, context) => { // Use Unchained context to fetch data const products = await context.modules.products.findProducts({}); return products.map(p => ({ id: p._id, name: p.texts?.title || 'Untitled', })); }, }), }), }); ``` ### Adding Mutations ```typescript builder.mutationType({ fields: (t) => ({ createCustomEntry: t.field({ type: 'String', args: { input: t.arg({ type: builder.inputType('CreateCustomEntryInput', { fields: (t) => ({ name: t.string({ required: true }), value: t.int({ required: true }), }), }), required: true, }), }, resolve: async (parent, { input }, context) => { // Your custom mutation logic return `Created: ${input.name}`; }, }), }), }); ``` This approach is useful when you want type-safe schema definitions and auto-completion in your IDE. --- ## Carts As you have learned already, in Unchained Engine, a cart is an order in initial `OPEN` state. When using the cart mutation API's, Unchained uses the `findOrInitCart` service to find or create a cart. It does that following this logic: First Unchained determines the shop country based on the locale provided or fallback to default country. Then it tries to find an `OPEN` order for that country. If an existing order has been found, that one will be used as the cart. If no order has been found, Unchained creates a new order for that user, providing: - Country - Currency - Billing address of the last order if possible - Contact information of the last order if possible Because billing address and contact information could be undefined, before you can checkout, you have to make sure that order context is set and that the cart has add at least one order position. ## Order Context Every order has a `billingAddress` and a `contact`, both have to be set in order to do a checkout. It's up to you as a developer to define which fields have to be provided in order for you to process the checkout. For example if you only need a single phone number but no address and no e-mail address, this is valid: ```graphql mutation { updateCart(contact: { telNumber: "+41414114141" }, billingAddress: {}) { _id } } ``` Each order also has a `meta` context which is an arbitrary JSON object stored in the database that can be updated through `updateCart`. It can store various configurations required for custom plugins. This could be anything. If your checkout process involves an additional `comment` input field for example, that comment could be passed as meta context. Because this information is accessible by payment and delivery providers, available payment methods can be made dependendent on that data. ## Payment Provider configuration If you want to show available payment options, use `Order.supportedPaymentProvider` of a cart to get a list of payment providers available for that cart and `Order.payment` to get the current payment option set on the cart. `Order.supportedPaymentProviders` uses the service `supportedPaymentProviders` which determines all valid payment providers for that specific cart like that: 1. Gets all active Payment Providers configured 2. Filters and sorts the found providers by `filterSupportedProviders` customizable through the platform configuration To determine the default payment provider for initial carts, `determineDefaultProvider` platform settings function is used. ```graphql query { me { cart { payment { provider { _id } } supportedPaymentProviders { _id } } } } ``` By using `Mutation.updateCart` you can change the active payment provider for that order (input parameter `paymentProviderId`). To set payment provider related options, use `Mutation.updateCartPaymentInvoice` or `Mutation.updateCartPaymentGeneric`. ## Delivery Provider configuration If you want to show available delivery options, use `Order.supportedDeliveryProvider` of a cart to get a list of delivery providers available for that cart and `Order.delivery` to get the current delivery option set on the cart. `Order.supportedDeliveryProviders` uses the service `supportedDeliveryProviders` which determines all valid delivery providers for that specific cart like that: 1. Gets all active delivery providers configured 2. Filters and sorts the found providers by `filterSupportedProviders` customizable through the platform configuration To determine the default delivery provider for initial carts, `determineDefaultProvider` platform settings function is used. ```graphql query { me { cart { delivery { provider { _id } } supportedDeliveryProviders { _id } } } } ``` By using `Mutation.updateCart` you can change the active delivery provider for that order (input parameter `deliveryProviderId`). To set delivery provider related options, use `Mutation.updateCartDeliveryPickUp` or `Mutation.updateCartDeliveryShipping`. Pick up providers usually want a pickup location, shipping providers usually want to know an address to ship a parcel to. ## Discounts TBD ## Order Positions In Unchained, you can add products (`Mutation.addCartProduct`) and quotations (`Mutation.addCartQuotation`) to carts, but only products will remain in the cart in the end. When adding products to the cart, they are transformed according to the following rules: **Products:** - Adding a SimpleProduct or BundleProduct adds the product to the cart without transformation. - Adding a ConfigurableProduct resolves to a concrete product if enough variation parameters are provided. Otherwise, the operation fails. The variation configuration is stored on the resolved item along with user-provided parameters. When one product leads to another, the source productId is saved in `orderPosition.originalProductId`, maintaining a reference for UX purposes. **Quotations:** When adding a Quotation to the cart, the actual product is resolved and added. The quotation plugin system transforms a `quotationConfiguration` into a `productConfiguration`, and the source quotationId is saved in `orderPosition.originalProductId`. **Chaining Operations:** 1. `addCartQuotation` is called with quotation Y. 2. Quotation Y resolves to configurable product X with a specific configuration. 3. The configuration is handed to the vector logic to find a distinct concrete product Z. 4. Bundle product Z is resolved. The cart then looks like this: 1 x Bundle Z (e.g., a piece of furniture) ## Pricing and Delivery Date Invalidation With every cart mutation, Unchained Engine recomputes the cart in those steps: --- ## Write a Delivery Provider Plugin # Delivery Provider Plugins In order to register available delivery options, you either have to use the builtin ones or have to add a plugin to the supported delivery provider by implementing the `IDeliveryAdapter` interface and registering the adapter on the global `DeliveryDirector` There can be multiple delivery adapter implementation for a shop and all of them will be executed based on their `orderIndex` value. Delivery adapters with lowe `orderIndex` are executed first. Below we have sample delivery adapter ```typescript DeliveryDirector, DeliveryProviderType, } from "@unchainedshop/core-delivery"; const ShopPickUp: IDeliveryAdapter = { key: 'ch.shop.delivery.pickup', label: 'Pickup at Clerk', version: '1.0.0', orderIndex: 1 initialConfiguration: (DeliveryConfiguration = []), typeSupported: (type: DeliveryProviderType): boolean => { return type === DeliveryProviderType.PICKUP; }, actions: (config: DeliveryConfiguration, context: DeliveryAdapterContext, unchainedAPI: UnchainedCore): DeliveryAdapterActions => { return { isAutoReleaseAllowed(): boolean { return false; }, isActive(): boolean { return true; }, configurationError(transactionContext?: any) { return null; }, pickUpLocationById(locationId: string): Promise { return this.pickUpLocations().filter(({ _id }) => _id === locationId); }, estimatedDeliveryThroughput: (warehousingThroughputTime: number) : Promise => { return 0; }, pickUpLocations(): Promise> { return [ { _id: 'first-location-id', name: 'first-location', address: { addressLine: 'address-line', postalCode: '1234', countryCode: 'CH', city: 'Zurich', }, geoPoint: { latitude: 123456789, longitude: 987654321, }, }, ]; }, send: async (): Promise => { const { modules, order } = context as typeof context; await modules.worker.addWork( { type: 'MARK_ORDER_DELIVERED', retries: 0, scheduled: new Date(new Date().getTime() + 1000 * (24 * 60 * 60)), input: { orderDeliveryId: order.deliveryId, }, }, ); return false; }, }; }, }; ``` - **typeSupported(type: DeliveryProviderType)**: Defines which type of delivery providers this adapter support. - **configurationError(transactionContext: any): DeliveryError**: returns any issue found with the delivery adapter configuration. its passed current transaction object that lets you check if everything is working for proper functioning of the adapter. - **estimatedDeliveryThroughput(warehousingThroughputTime: number)**: Used to send an estimation delivery time of the adapter. - **isActive**: Used to enable or disable the adapter. - **isAutoReleaseAllowed**: Determined if the delivery provider should change status automatically or if manual confirmation of delivery is required. - **pickUpLocationById(locationId: string): DeliveryLocation**: returns a delivery location with the specified ID from the list of locations returned from `pickUpLocations`. - **pickUpLocations: DeliveryLocation[]** returns list of delivery locations available with a particular delivery adapter - **send: any**: Determines the if an order is delivered or not. if this function returns a trueish value the order delivery status will be changed to **DELIVERED**, if it returns false order delivery status stays the same (PENDING) but the order status can be changed but if it throws an error the order will be canceled. --- ## Write a Payment Provider Plugin # Payment Provider Plugins Payment adapters handle payment processing for orders. Unchained supports multiple payment types (`CARD`, `INVOICE`, `GENERIC`) and you can implement custom adapters for any payment gateway. For an overview of how payment fits into the order lifecycle, see [Order Lifecycle](../../../concepts/order-lifecycle). ## Payment Types | Type | Description | Use Cases | |------|-------------|-----------| | `CARD` | Credit/debit card payments | Stripe, PayPal, Braintree | | `INVOICE` | Invoice-based payments | Pre-paid or post-paid invoices | | `GENERIC` | Other payment methods | Crypto, bank transfer, cash | ## Creating a Payment Adapter Implement the `IPaymentAdapter` interface and register it with the `PaymentDirector`. ### Example: Pre-Paid Invoice This example shows a pre-paid invoice provider that blocks order confirmation until payment is received: ```typescript PaymentDirector, type IPaymentAdapter, type PaymentChargeActionResult, } from '@unchainedshop/core'; const PrePaidInvoice: IPaymentAdapter = { key: 'shop.example.payment.prepaid-invoice', label: 'Pre-Paid Invoice', version: '1.0.0', // Initial configuration (optional) initialConfiguration: [], // Which payment types this adapter supports typeSupported(type) { return type === 'INVOICE'; }, actions(params) { const { context, paymentContext } = params; const { order } = paymentContext; const { modules } = context; return { // Return configuration errors (e.g., missing API keys) configurationError() { return null; }, // Is this adapter active for the current context? isActive() { return true; }, // Can the order be confirmed before payment? // false = payment must complete first (pre-paid) // true = order can proceed without payment (post-paid) isPayLaterAllowed() { return false; }, // Process payment charge async charge(): Promise { // For pre-paid invoice: // - Return false: payment not yet received, stay in PENDING // - Return { transactionId }: payment received, proceed // - Throw error: abort checkout entirely return false; }, // Register a payment method (e.g., save card for future use) async register() { return { token: '' }; }, // Sign a payment request (e.g., for client-side SDK initialization) async sign() { return ''; }, // Validate a payment token async validate(token) { return true; }, // Cancel/refund payment async cancel() { return true; }, // Confirm a previously authorized payment async confirm() { return { transactionId: '' }; }, }; }, }; // Register the adapter PaymentDirector.registerAdapter(PrePaidInvoice); ``` ## Adapter Methods Reference ### `typeSupported(type)` Determines which payment types this adapter handles. ```typescript typeSupported(type) { return type === 'CARD'; } ``` ### `configurationError()` Return any configuration errors. Called when validating the provider setup. ```typescript configurationError() { if (!process.env.PAYMENT_API_KEY) { return { code: 'MISSING_API_KEY', message: 'Payment API key is required' }; } return null; } ``` ### `isActive()` Determines if the adapter is active for the current transaction context. ```typescript isActive() { // Disable for specific countries const { order } = this.paymentContext; return order.countryCode !== 'BLOCKED_COUNTRY'; } ``` ### `isPayLaterAllowed()` Controls whether order confirmation can proceed before payment completes. | Return Value | Behavior | |--------------|----------| | `true` | Order can be confirmed without payment (post-paid) | | `false` | Payment must complete before order confirmation (pre-paid) | ```typescript isPayLaterAllowed() { // Post-paid invoice: allow order to proceed return true; } ``` ### `charge()` Process the payment charge. This is called during checkout. | Return Value | Behavior | |--------------|----------| | `{ transactionId }` | Payment successful, proceed with checkout | | `false` | Payment not complete yet, order stays in PENDING | | Throws error | Abort checkout, order stays in OPEN (cart) | ```typescript async charge() { try { const result = await paymentGateway.charge({ amount: order.pricing().total().amount, currency: order.currency, }); return { transactionId: result.id }; } catch (error) { // Throw to abort checkout throw new Error('Payment failed: ' + error.message); } } ``` ### `register()` Register a payment method for future use (e.g., save a credit card). ```typescript async register() { const token = await paymentGateway.createCustomer(user); return { token }; } ``` ### `sign()` Sign a payment request for client-side SDK initialization. ```typescript async sign() { // Create a client token for Stripe Elements, PayPal buttons, etc. const clientSecret = await paymentGateway.createPaymentIntent({ amount: order.pricing().total().amount, }); return clientSecret; } ``` ### `validate(token)` Validate a payment token. ```typescript async validate(token) { const isValid = await paymentGateway.validateToken(token); return isValid; } ``` ### `cancel()` Cancel or refund a payment. Called when an order is rejected. ```typescript async cancel() { const { orderPayment } = this.paymentContext; if (orderPayment.transactionId) { await paymentGateway.refund(orderPayment.transactionId); } return true; } ``` ### `confirm()` Confirm a previously authorized payment. Called when order transitions to CONFIRMED. ```typescript async confirm() { const { orderPayment } = this.paymentContext; const result = await paymentGateway.capturePayment(orderPayment.transactionId); return { transactionId: result.id }; } ``` ## Webhook Integration Most payment gateways require webhooks for async payment confirmations. Create an endpoint to handle these: ```typescript const app = express(); app.post('/webhooks/payment', async (req, res) => { const event = req.body; if (event.type === 'payment_intent.succeeded') { const { orderId } = event.data.metadata; // Confirm the order await modules.orders.checkout(orderId, { transactionId: event.data.id, }); } res.json({ received: true }); }); ``` ## Example: Card Payment with Stripe ```typescript const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const StripePayment: IPaymentAdapter = { key: 'shop.example.payment.stripe', label: 'Stripe Card Payment', version: '1.0.0', typeSupported(type) { return type === 'CARD'; }, actions(params) { const { paymentContext } = params; const { order, orderPayment } = paymentContext; return { configurationError() { if (!process.env.STRIPE_SECRET_KEY) { return { code: 'STRIPE_KEY_MISSING' }; } return null; }, isActive() { return true; }, isPayLaterAllowed() { return false; }, async sign() { const paymentIntent = await stripe.paymentIntents.create({ amount: order.pricing().total().amount, currency: order.currency.toLowerCase(), metadata: { orderId: order._id }, }); return paymentIntent.client_secret; }, async charge() { // Payment is confirmed via webhook if (orderPayment.context?.paymentIntentId) { const intent = await stripe.paymentIntents.retrieve( orderPayment.context.paymentIntentId ); if (intent.status === 'succeeded') { return { transactionId: intent.id }; } } return false; }, async cancel() { if (orderPayment.transactionId) { await stripe.refunds.create({ payment_intent: orderPayment.transactionId, }); } return true; }, async confirm() { return { transactionId: orderPayment.transactionId }; }, async register() { return { token: '' }; }, async validate() { return true; }, }; }, }; PaymentDirector.registerAdapter(StripePayment); ``` ## Related - [Director/Adapter Pattern](../../../concepts/director-adapter-pattern) - Understanding the plugin architecture - [Order Lifecycle](../../../concepts/order-lifecycle) - How payment fits into checkout - [Stripe Plugin](../../../plugins/payment/stripe) - Stripe payment adapter --- ## Write a Warehousing Provider Plugin # Warehousing Provider Plugins ## WarehousingAdapter You can define a custom Warehousing adapter to simulate the stock availability. In order to define a warehousing adapter you should implement the `IWarehousingAdapter` and register it to the global warehousing director that implements the `IWarehousingDirector` interface. A store can have multiple Warehousing adapters configured and all of them are executed ordered by there `orderIndex` value. Warehousing adapters with lower `orderIndex` are executed first. Below is a simple warehousing adapter implementation that will always show a stock is always available for all products. ```typescript IWarehousingAdapter, WarehousingError, WarehousingAdapterActions, WarehousingContext, WarehousingProviderType, } from '@unchainedshop/core-warehousing'; const Store: IWarehousingAdapter = { key: 'shop.unchained.warehousing.store', version: '1.0.0', label: 'Store', orderIndex: 0, initialConfiguration = [{ key: 'name', value: 'Flagship Store' }], typeSupported: (type: WarehousingProviderType): boolean => { return type === WarehousingProviderType.PHYSICAL; }, actions: ( config: WarehousingConfiguration, context: WarehousingContext & Context, ): WarehousingAdapterActions => { return { isActive: async (): boolean => { return true; }, configurationError: async () => { return null; }, stock: async (referenceDate: Date): Promise => { return 99999; }, productionTime: async (quantityToProduce: number): Promise => { return 0; }, commissioningTime: async (quantity: number): Promise => { return 0; }, }; }, }; ``` - **typeSupported(type: WarehousingProviderType)**: Defines the warehousing provider type an adapter is valid for. - **isActive**: Defines if the adapter is valid or not based any conditions you set. - **configurationError(): WarehousingError**: Any error that occurred during the initialization of an adapter. it can be a missing env or any value missing for a proper functioning of the adapter. - **stock(referenceDate: Date)**: It should return the available stock of a product for the provided reference date. in the example above we are simply returning `99999` as stock count. - **productionTime(quantityToProduct: number)**: Returns an estimate to produce number of product passed as an argument. - **commissioningTime(quantity: number)**: number of days required to product a quantity passed as an argument ## Register warehousing adapter ```typescript WarehousingDirector.registerAdapter(Store); ``` --- ## Fulfilment Process # Order Fulfilment The fulfilment process in Unchained Engine involves several key steps to ensure that orders are processed efficiently and accurately. This document provides a high-level overview of the process. ## Order Processor :::note Locking Unchained uses "Distributed Locking" during checkout, order confirmation and order rejection. All of those services trigger the order processor state machine. ::: :::note State Persistence Every time the order processor persists an order status in the DB (think "auto-save" in games), order status notification messages are triggered asynchronously. If it does not persist, the status is in memory. ::: ### `OPEN` (Cart) An Order starts it's life with a status of `null` indicating it's a cart. A cart always has a userId, thus when a client wants to add something to a cart through the GraphQL API, the user has to be either logged in or have used `Mutation.loginAsGuest` to start a guest user session. Carts by default only exist when at least one cart mutation has been called (created and re-used on demand), before that `Query.me.cart` is `null`. This behavior can be customized. With every cart mutation, prices and delivery dates get re-calculated. Reading a cart is side-effect free. More about this topic can be read the next chapter [Cart Behavior](./carts.md). ### `OPEN` => `PENDING` (Checkout) Order checkout is usually called directly from payment plugin webhooks server-to-server. In error-ish cases, you might want to call the method on the client too to analyze errors that happened during the checkout. When a checkout is initiated, in a first step the order gets validated. This is done by a few checks: 1. Order has a payment provider set 2. Order has a delivery provider set 3. At least one order position present 4. Checks every order position by calling `validateOrderPosition` which can be customized by providing an own implementation through the platform settings for orders. By default it just checks if the product is still active. 5. If the order position is a quotation proposal, we additionally ask the Quotation plugin in charge if the proposal is still valid. The Order validation step **DOES NOT** recalculate the order, so prices and delivery dates could have been changed since the last cart mutation. If you need such behavior, throw an Error in `validateOrderPosition` and let the client application fix the problem. With all checks complete, the order goes into status `PENDING` but as an in-flight status that is not yet persisted in the database. ### `PENDING` => `CONFIRMED` (Confirmation) The system now proceeds with payment. It hands this over to the `PaymentDirector` which first tries to charge with an order-assigned payment provider. If the plugin **throws an error**, the whole checkout gets interrupted and the order stays in status `OPEN`. This can be helpful if for example a credit card got declined and you don't want to allow the checkout to go further. If the payment provider is not successful (`charge()` returns `false`) but doesn't throw an error, Unchained will assume that it could for some reason be okay to have this order continue in the process without a payment. A successful charge means the payment has been done or the payment has been done already and is still valid. Unchained then asks both the delivery and the payment plugin if it is allowed to automatically confirm the order. :::info A post-paid invoice plugin for example would usually not block order confirmation because it's fine to let it deliver without payment so it returns `true` in the payment adapter's `isPayLaterAllowed`. A pay by invoice pre-paid plugin would usually block the order confirmation. ::: If order confirmation is blocked, checkout ends here and the cart transitions to a persisted order with status `PENDING` waiting for events or manual confirmation to proceed. If an order confirmation is not beeing blocked by the plugins, Unchained will do some last actions: 1. Tell the payment plugin it can confirm the payment (payment could have been only reserved until now). 3. Finally, the order will go into status `CONFIRMED` and also persist this status in the db. ### `PENDING` => `REJECTED` (Rejection) An order can be rejected if it is persisted in `PENDING` state. This is usually done through a manual API call like `Mutation.rejectOrder`. It first hands this action over to the `PaymentDirector`. The PaymentDirector calls the method `cancel()` of the payment adapter plugin in charge. If cancel throws, order will stay in status `PENDING` and the process is interrupted. Else, the order will be persisted in final status `REJECTED`. ### `CONFIRMED` => `FULFILLED` (Fulfillment) The system now proceeds with delivery. It first hands this over to the `DeliveryDirector` which tries to initiate and complete delivery. If the delivery plugin **throws an error**, the process get interrupted, checkouts would error and the order stays in status `CONFIRMED`. If the delivery plugin is not successful (`send()` returns `false`), Unchained will assume that delivery is not complete yet. No matter if the delivery is successful or not, next, Unchained will also iterate through all order positions and trigger follow-up actions: - `WarehousingDirector` digitally instantiates (`tokenize`) order positions for TokenizedProduct. - `EnrollmentDirector` creates enrollments (`transformOrderItemToEnrollment`) for PlanProducts. - `QuotationDirector` marks linked quotations as fulfilled because the offer has been accepted through order fulfillment. After that Unchained checks if order delivery is `DELIVERED` and order payment is `PAID`. If that is the case, the order is persisted with the final status `FULFILLED`, else it will stay `CONFIRMED`. :::danger It's discouraged to write plugins that throw If any of the above actions throw because of your own code in for ex. a `WarehousingAdapter`, the process gets interrupted in an unsupported state. Resolving that state needs custom code and deep knowledge about the inner workings! There is no standard API to retry those actions, triggering the Order Processor will not retry those actions either. Make sure to build these actions in a way that is asynchronous and forgiving so that this step is not dependent on potentially unavailable resources. If you want to send the order to an ERP system with your own delivery plugin for example, consider returning `false` and create a work queue item. It will also make your checkouts fast as hell as a side-effect 😁 ::: --- ## Delivery Pricing Delivery pricing adapters calculate shipping and handling fees based on order contents, delivery method, and destination. For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). ## Creating an Adapter Extend `DeliveryPricingAdapter` and register it with `DeliveryPricingDirector`: ```typescript DeliveryPricingAdapter, DeliveryPricingDirector, } from '@unchainedshop/core-pricing'; class MyDeliveryPricing extends DeliveryPricingAdapter { static key = 'my-shop.pricing.delivery'; static version = '1.0.0'; static label = 'Custom Delivery Pricing'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'SHIPPING'; } async calculate() { this.result.addItem({ amount: 800, // 8.00 flat rate isTaxable: true, isNetPrice: true, category: 'DELIVERY', meta: { adapter: this.constructor.key }, }); return super.calculate(); } } DeliveryPricingDirector.registerAdapter(MyDeliveryPricing); ``` ## Examples ### Weight-Based Shipping ```typescript class WeightBasedShipping extends DeliveryPricingAdapter { static key = 'my-shop.pricing.weight-shipping'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'SHIPPING'; } async calculate() { const { order, modules } = this.context; const items = await modules.orders.positions.findOrderPositions({ orderId: order._id, }); // Calculate total weight let totalWeight = 0; for (const item of items) { const product = await modules.products.findProduct({ productId: item.productId }); totalWeight += (product?.warehousing?.weight || 0) * item.quantity; } // Price: base + per kg const basePrice = 500; // 5.00 base const pricePerKg = 200; // 2.00 per kg this.result.addItem({ amount: basePrice + Math.round(totalWeight * pricePerKg), isTaxable: true, isNetPrice: true, category: 'DELIVERY', meta: { weight: totalWeight, adapter: this.constructor.key }, }); return super.calculate(); } } ``` ### Zone-Based Pricing ```typescript class ZoneBasedShipping extends DeliveryPricingAdapter { static key = 'my-shop.pricing.zone-shipping'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'SHIPPING'; } async calculate() { const { order } = this.context; const countryCode = order.delivery?.address?.countryCode; const zoneRates = { CH: 800, // 8.00 domestic DE: 1500, // 15.00 EU neighbor AT: 1500, FR: 1500, IT: 1500, default: 2500, // 25.00 international }; const amount = zoneRates[countryCode] || zoneRates.default; this.result.addItem({ amount, isTaxable: true, isNetPrice: true, category: 'DELIVERY', meta: { zone: countryCode, adapter: this.constructor.key }, }); return super.calculate(); } } ``` ### Free Shipping Threshold ```typescript class FreeShippingThreshold extends DeliveryPricingAdapter { static key = 'my-shop.pricing.free-shipping'; static orderIndex = 10; // After base shipping async calculate() { const { order, modules } = this.context; const threshold = 10000; // Free shipping over 100.00 // Calculate product total const items = await modules.orders.positions.findOrderPositions({ orderId: order._id, }); const productTotal = items.reduce((sum, item) => { return sum + (item.calculation?.find(c => c.category === 'BASE')?.amount || 0); }, 0); if (productTotal >= threshold) { const deliveryTotal = this.calculation.sum({ category: 'DELIVERY' }); if (deliveryTotal > 0) { this.result.addItem({ amount: -deliveryTotal, isTaxable: true, isNetPrice: true, category: 'DISCOUNT', meta: { type: 'free-shipping', threshold, adapter: this.constructor.key }, }); } } return super.calculate(); } } ``` ### Express Shipping Option ```typescript class ExpressShipping extends DeliveryPricingAdapter { static key = 'my-shop.pricing.express'; static orderIndex = 0; static isActivatedFor({ provider }) { // Only for express delivery provider return provider.adapterKey === 'my-shop.delivery.express'; } async calculate() { const { order } = this.context; // Express: 2x standard rate const standardRate = 800; const expressMultiplier = 2; this.result.addItem({ amount: standardRate * expressMultiplier, isTaxable: true, isNetPrice: true, category: 'DELIVERY', meta: { type: 'express', adapter: this.constructor.key }, }); return super.calculate(); } } ``` ## Context Properties Available in `this.context`: | Property | Description | |----------|-------------| | `provider` | The delivery provider | | `order` | The current order | | `modules` | Access to all modules | | `currency` | Currency code | ## Related - [Pricing System](../../concepts/pricing-system.md) - Conceptual overview - [Product Pricing](./product-pricing.md) - Product prices - [Payment Pricing](./payment-pricing.md) - Payment fees - [Delivery Plugins](../order-fulfilment/fulfilment-plugins/delivery.md) - Delivery adapters --- ## Order Discounts Order discount adapters handle coupon codes, promotional discounts, and automatic order-level discounts. For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). ## Creating an Adapter Register a discount adapter with `OrderDiscountDirector`: ```typescript const MyDiscount: IDiscountAdapter = { key: 'my-shop.discount.custom', label: 'Custom Discount', version: '1.0.0', orderIndex: 0, isManualAdditionAllowed(code) { return true; // Allow users to enter this discount code }, isManualRemovalAllowed() { return true; // Allow users to remove this discount }, actions(context) { return { isValidForSystemTriggering() { return false; // Don't auto-apply }, isValidForCodeTriggering(code) { return code === 'SAVE10'; }, discountForPricingAdapterKey({ pricingAdapterKey }) { return { rate: 0.1 }; // 10% off }, async reserve(code) { // Optional: Track usage }, async release() { // Optional: Release reservation on cancellation }, }; }, }; OrderDiscountDirector.registerAdapter(MyDiscount); ``` ## Examples ### Coupon Code Discount ```typescript const CouponDiscount: IDiscountAdapter = { key: 'my-shop.discount.coupon', label: 'Coupon Code', version: '1.0.0', orderIndex: 0, isManualAdditionAllowed(code) { // Accept codes starting with 'SAVE' or 'DISCOUNT' return code?.startsWith('SAVE') || code?.startsWith('DISCOUNT'); }, isManualRemovalAllowed() { return true; }, actions(context) { const validCodes = { SAVE10: { rate: 0.1 }, SAVE20: { rate: 0.2 }, DISCOUNT50: { fixedRate: 5000 }, // 50.00 off }; return { isValidForSystemTriggering() { return false; }, isValidForCodeTriggering(code) { return code in validCodes; }, discountForPricingAdapterKey({ code }) { return validCodes[code] || null; }, async reserve(code) { // Decrement coupon usage count await db.collection('coupons').updateOne( { code }, { $inc: { usageCount: 1 } } ); }, async release() { // Increment back on cancellation const { code } = context.orderDiscount; await db.collection('coupons').updateOne( { code }, { $inc: { usageCount: -1 } } ); }, }; }, }; ``` ### Automatic First-Order Discount ```typescript const FirstOrderDiscount: IDiscountAdapter = { key: 'my-shop.discount.first-order', label: 'First Order Discount', version: '1.0.0', orderIndex: 1, isManualAdditionAllowed() { return false; // Auto-applied only }, isManualRemovalAllowed() { return false; }, actions(context) { const { order, modules } = context; return { async isValidForSystemTriggering() { // Check if this is the user's first order const previousOrders = await modules.orders.count({ userId: order.userId, status: { $ne: null }, // Exclude carts }); return previousOrders === 0; }, isValidForCodeTriggering() { return false; }, discountForPricingAdapterKey() { return { rate: 0.15 }; // 15% off first order }, async reserve() {}, async release() {}, }; }, }; ``` ### Minimum Order Value Discount ```typescript const MinimumOrderDiscount: IDiscountAdapter = { key: 'my-shop.discount.minimum-order', label: 'Spend More Save More', version: '1.0.0', orderIndex: 2, isManualAdditionAllowed() { return false; }, isManualRemovalAllowed() { return false; }, actions(context) { const { order } = context; return { async isValidForSystemTriggering() { const total = order.pricing().total().amount; return total >= 10000; // Minimum 100.00 }, isValidForCodeTriggering() { return false; }, discountForPricingAdapterKey() { const total = order.pricing().total().amount; // Tiered discounts if (total >= 50000) { return { rate: 0.15 }; // 15% off for 500+ } else if (total >= 25000) { return { rate: 0.1 }; // 10% off for 250+ } else if (total >= 10000) { return { rate: 0.05 }; // 5% off for 100+ } return null; }, async reserve() {}, async release() {}, }; }, }; ``` ### Limited-Use Coupon ```typescript const LimitedCoupon: IDiscountAdapter = { key: 'my-shop.discount.limited', label: 'Limited Coupon', version: '1.0.0', orderIndex: 0, isManualAdditionAllowed(code) { return code?.startsWith('LIMITED'); }, isManualRemovalAllowed() { return true; }, actions(context) { return { isValidForSystemTriggering() { return false; }, async isValidForCodeTriggering(code) { // Check if coupon exists and has remaining uses const coupon = await db.collection('coupons').findOne({ code }); if (!coupon) return false; return coupon.usageCount < coupon.maxUsage; }, discountForPricingAdapterKey({ code }) { return { rate: 0.25 }; // 25% off }, async reserve(code) { await db.collection('coupons').updateOne( { code }, { $inc: { usageCount: 1 } } ); }, async release() { const { code } = context.orderDiscount; await db.collection('coupons').updateOne( { code }, { $inc: { usageCount: -1 } } ); }, }; }, }; ``` ## Adapter Methods | Method | Description | |--------|-------------| | `isManualAdditionAllowed(code)` | Can users add this discount with a code? | | `isManualRemovalAllowed()` | Can users remove this discount? | | `isValidForSystemTriggering()` | Should this discount auto-apply? | | `isValidForCodeTriggering(code)` | Is this code valid? | | `discountForPricingAdapterKey()` | Return discount configuration | | `reserve(code)` | Called when discount is applied | | `release()` | Called when order is cancelled | ## Discount Configuration Return from `discountForPricingAdapterKey`: | Property | Description | |----------|-------------| | `rate` | Percentage discount (0.1 = 10%) | | `fixedRate` | Fixed amount in cents (5000 = 50.00) | ## GraphQL Apply discount: ```graphql mutation ApplyDiscount($code: String!) { addCartDiscount(code: $code) { _id code total { amount currencyCode } } } ``` Remove discount: ```graphql mutation RemoveDiscount($discountId: ID!) { removeCartDiscount(discountId: $discountId) { _id } } ``` ## Related - [Pricing System](../../concepts/pricing-system.md) - Conceptual overview - [Product Pricing](./product-pricing.md) - Product-level discounts --- ## Payment Pricing Payment pricing adapters calculate fees for different payment methods, such as credit card processing fees or invoice handling charges. For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). ## Creating an Adapter Extend `PaymentPricingAdapter` and register it with `PaymentPricingDirector`: ```typescript PaymentPricingAdapter, PaymentPricingDirector, } from '@unchainedshop/core-pricing'; class MyPaymentPricing extends PaymentPricingAdapter { static key = 'my-shop.pricing.payment'; static version = '1.0.0'; static label = 'Custom Payment Pricing'; static orderIndex = 0; static isActivatedFor({ provider }) { return true; // Activate for all payment providers } async calculate() { this.result.addItem({ amount: 0, // No fee isTaxable: false, isNetPrice: true, category: 'PAYMENT', meta: { adapter: this.constructor.key }, }); return super.calculate(); } } PaymentPricingDirector.registerAdapter(MyPaymentPricing); ``` ## Examples ### Credit Card Fee ```typescript class CardFeeAdapter extends PaymentPricingAdapter { static key = 'my-shop.pricing.card-fee'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'CARD' || provider.type === 'GENERIC'; } async calculate() { const { order } = this.context; const orderTotal = order.pricing().total().amount; // 2.9% + 30 cents (typical card processing fee) const fee = Math.round(orderTotal * 0.029 + 30); this.result.addItem({ amount: fee, isTaxable: false, isNetPrice: true, category: 'PAYMENT', meta: { rate: 0.029, fixed: 30, adapter: this.constructor.key }, }); return super.calculate(); } } ``` ### Invoice Fee ```typescript class InvoiceFeeAdapter extends PaymentPricingAdapter { static key = 'my-shop.pricing.invoice-fee'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'INVOICE'; } async calculate() { // Flat fee for invoice handling this.result.addItem({ amount: 500, // 5.00 invoice fee isTaxable: true, isNetPrice: true, category: 'PAYMENT', meta: { type: 'invoice', adapter: this.constructor.key }, }); return super.calculate(); } } ``` ### Discount for Bank Transfer ```typescript class BankTransferDiscountAdapter extends PaymentPricingAdapter { static key = 'my-shop.pricing.bank-discount'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.adapterKey === 'my-shop.payment.bank-transfer'; } async calculate() { const { order } = this.context; const orderTotal = order.pricing().total().amount; // 2% discount for bank transfer (no card fees) const discount = Math.round(orderTotal * 0.02); this.result.addItem({ amount: -discount, isTaxable: true, isNetPrice: true, category: 'DISCOUNT', meta: { type: 'bank-transfer-discount', rate: 0.02 }, }); return super.calculate(); } } ``` ### Tiered Processing Fees ```typescript class TieredFeeAdapter extends PaymentPricingAdapter { static key = 'my-shop.pricing.tiered-fee'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'CARD'; } async calculate() { const { order } = this.context; const orderTotal = order.pricing().total().amount; // Tiered rates based on order value let rate: number; if (orderTotal >= 50000) { rate = 0.019; // 1.9% for orders >= 500 } else if (orderTotal >= 10000) { rate = 0.025; // 2.5% for orders >= 100 } else { rate = 0.029; // 2.9% for smaller orders } const fee = Math.round(orderTotal * rate + 30); this.result.addItem({ amount: fee, isTaxable: false, isNetPrice: true, category: 'PAYMENT', meta: { rate, orderTotal, adapter: this.constructor.key }, }); return super.calculate(); } } ``` ## Context Properties Available in `this.context`: | Property | Description | |----------|-------------| | `provider` | The payment provider | | `order` | The current order | | `modules` | Access to all modules | | `currency` | Currency code | ## Related - [Pricing System](../../concepts/pricing-system.md) - Conceptual overview - [Product Pricing](./product-pricing.md) - Product prices - [Delivery Pricing](./delivery-pricing.md) - Shipping fees - [Payment Plugins](../order-fulfilment/fulfilment-plugins/payment.md) - Payment adapters --- ## Product Pricing Product pricing adapters calculate prices when products are queried or added to cart. Use them to implement taxes, discounts, rounding, and currency conversion. For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). ## Creating an Adapter Extend `ProductPricingAdapter` and register it with `ProductPricingDirector`: ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; class MyProductPricing extends ProductPricingAdapter { static key = 'my-shop.pricing.custom'; static version = '1.0.0'; static label = 'Custom Product Pricing'; static orderIndex = 0; static isActivatedFor({ product, currencyCode }) { return true; // Activate for all products } async calculate() { const { product, quantity, currencyCode } = this.context; this.result.addItem({ amount: 1000, // 10.00 in cents isTaxable: true, isNetPrice: true, category: 'BASE', meta: { adapter: this.constructor.key }, }); return super.calculate(); } } ProductPricingDirector.registerAdapter(MyProductPricing); ``` ## Examples ### Tax Calculation ```typescript class SwissTaxAdapter extends ProductPricingAdapter { static key = 'my-shop.pricing.swiss-tax'; static orderIndex = 20; // After base price and discounts static isActivatedFor({ country }) { return country === 'CH'; } async calculate() { const taxRate = 0.081; // 8.1% Swiss VAT const taxableAmount = this.calculation.sum({ isTaxable: true }); if (taxableAmount > 0) { this.result.addItem({ amount: Math.round(taxableAmount * taxRate), isTaxable: false, isNetPrice: false, category: 'TAX', meta: { rate: taxRate, adapter: this.constructor.key }, }); } return super.calculate(); } } ``` ### Bulk Discount ```typescript class BulkDiscountAdapter extends ProductPricingAdapter { static key = 'my-shop.pricing.bulk-discount'; static orderIndex = 10; // After base price, before tax async calculate() { const { quantity } = this.context; if (quantity >= 10) { const baseTotal = this.calculation.sum({ category: 'BASE' }); const discountRate = 0.1; // 10% off this.result.addItem({ amount: -Math.round(baseTotal * discountRate), isTaxable: true, isNetPrice: true, category: 'DISCOUNT', meta: { type: 'bulk', rate: discountRate }, }); } return super.calculate(); } } ``` ### Price Rounding ```typescript class PriceRoundingAdapter extends ProductPricingAdapter { static key = 'my-shop.pricing.rounding'; static orderIndex = 30; // Run last async calculate() { const { calculation = [] } = this; if (calculation.length) { const [basePrice] = calculation; const rounded = this.roundToNext(basePrice.amount, 50); this.resetCalculation(); this.result.addItem({ amount: rounded, isTaxable: basePrice.isTaxable, isNetPrice: basePrice.isNetPrice, meta: { adapter: this.constructor.key }, }); } return super.calculate(); } roundToNext(value: number, precision: number) { const remainder = value % precision; return remainder === 0 ? value : value + (precision - remainder); } } ``` ### Currency Conversion ```typescript class CurrencyConversionAdapter extends ProductPricingAdapter { static key = 'my-shop.pricing.currency'; static orderIndex = 1; async calculate() { const { currencyCode, baseCurrencyCode } = this.context; if (currencyCode !== baseCurrencyCode) { const rate = await this.getExchangeRate(baseCurrencyCode, currencyCode); for (const item of this.calculation) { item.amount = Math.round(item.amount * rate); } } return super.calculate(); } async getExchangeRate(from: string, to: string) { // Fetch from your exchange rate service return 1.1; } } ``` ## Adapter Properties | Property | Type | Description | |----------|------|-------------| | `key` | string | Unique identifier | | `version` | string | Version for tracking | | `label` | string | Human-readable name | | `orderIndex` | number | Execution order (lower = earlier) | ## Context Properties Available in `this.context`: | Property | Description | |----------|-------------| | `product` | The product being priced | | `quantity` | Quantity requested | | `currencyCode` | Target currency | | `country` | Country code | ## Related - [Pricing System](../../concepts/pricing-system.md) - Conceptual overview - [Delivery Pricing](./delivery-pricing.md) - Shipping fees - [Payment Pricing](./payment-pricing.md) - Payment fees - [Order Discounts](./order-discounts.md) - Order-level discounts --- ## Quotations # Quotation Adapters ## QuotationAdapter You can accept quotation requests for a shop items. For every quotation received you can setup a quotation adapter to process this request manually or automatically. In order to process quotation request you need to create a quotation adapter that implements the `IQuotationAdapter` and register the adapter on the global quotation director that implements the `IQuotationDirector`. There can be multiple quotation adapters configured and active for a store and all of them will be executed for every quotation requests based on there `orderIndex` value. Quotation adapters that have smaller `orderIndex` value will be executed first. Below is a sample manual quotation adapter implementation that will mark every quotation request as expired after an hour of request if no quote is given in between by a user that is managing quotation requests. ```typescript export const ManualOffering: IQuotationAdapter = { key: 'shop.unchained.quotations.manual', label: 'Manual quotation' version: '1.0.0', orderIndex: 1, isActivatedFor: (quotationContext: QuotationContext, unchainedAPI: UnchainedCore): boolean => { return false; }, actions: (params: QuotationContext & Context): QuotationAdapterActions => { return { configurationError: () => { return QuotationError.NOT_IMPLEMENTED; }, isManualRequestVerificationRequired: async (): Promise => { return true; }, isManualProposalRequired: async (): Promise => { return true; }, quote: async (): Promise => { return { expires: new Date(new Date().getTime() + 3600 * 1000), }; }, rejectRequest: async (unchainedAPI?: any): Promise => { return true; }, submitRequest: async (unchainedAPI?: any): Promise => { return true; }, verifyRequest: async (unchainedAPI?: any): Promise => { return true; }, transformItemConfiguration: async (params: QuotationItemConfiguration) => { return { quantity: params.quantity, configuration: params.configuration }; }, }; }, }; ``` - **isActivatedFor: (quotationContext: QuotationContext, unchainedAPI: UnchainedCore)**: Determines for which type of quotation request an adapter is active for. it can be based on the actual quotation in question or any condition you can think of. - **configurationError: QuotationError**: Returns any error that occurred while initializing the adapter. it can be missing environment variable or and other missing required values. - **isManualRequestVerificationRequired**: defines if a quotation should be considered valid and ready for quote automatically or should be verified by someone manually. - **isManualProposalRequired** Define if a user can respond to quotation request manually or not. - **quote**: Responds with the actual quotation request. - **rejectRequest** Will mark a quotation as rejected if returned to based on any condition check performed. - **submitRequest**: Will approve a quotation request for processing if you return true from this function. - **verifyRequest** It will mark the quotation as verified for a certain quotation if this function returns true. - **transformItemConfiguration(params: QuotationItemConfiguration)**: A quotation request is submitted as a `JSON` value and there is no predefined format of quotation request. use this function to transform the submitted `JSON` from the front end into a structure that will be best to work with in an adapter. ## Registering Quotation Adapter ```typescript QuotationDirector.registerAdapter(ManualOffering); ``` --- ## Work Queue(Extend) ## WorkAdapter You can add different types of works to perform various task based on different input and triggers. Work can be a cron operation that run on a given interval to do a system backup or send an email to a user after a certain operation. In order to make use of work to perform any task you need to implement the `IWorkerAdapter` interface and register it to the global WorkDirector which implements the `IWorkerDirector`. Below is an example of work adapter that checks if all works are healthy and working correctly, runs on the `wait` interval value passed as input ```typescript const wait = async (time: number) => { return new Promise((resolve) => { setTimeout(() => { resolve(true); }, time); }); }; type Arg = { wait?: number; fails?: boolean; }; type Result = Arg; const Heartbeat: IWorkerAdapter = { key: 'shop.unchained.worker-plugin.heartbeat', label: 'Heartbeat plugin to check if workers are working', version: '1.0.0', type: 'HEARTBEAT', doWork: async (input: Arg): Promise<{ success: boolean; result: Result }> => { if (input?.wait) { await wait(input.wait); } if (input?.fails) { return { success: false, result: input, }; } return { success: true, result: input, }; }, }; ``` - **type**: type of the worker, this value is used to specify the worker you are targeting when adding a work to a work queue using `WorkerModule.addWork(data: WorkData, userId: string)` function - **doWork**: function that defines the actual work that is going to be performed by the work adapter ## Registering Work Adapter Before you can add a worker in the work queue you need to register it to the global Worker director ```typescript WorkerDirector.registerAdapter(Heartbeat); ``` ## Adding work to the work queue Triggering a worker is done by adding a work to the work queue using the worker module found on unchained context. below is an example that demonstrate adding the work adapter we have created above ```typescript unchainedAPI.modules.worker.addWork( { type: 'HEARTBEAT', retries: 0, input: { wait: 1000, }, }, ); } ``` --- ## Building a Storefront This guide covers how to build a **production-ready storefront** on top of **Unchained Engine’s GraphQL API**. Unchained is not just a product API β€” it is a **commerce engine**. Prices, texts, taxes, availability, shipping and discounts are resolved dynamically based on **context**. ## Overview Unchained Engine is headless, meaning it provides a GraphQL API that any frontend can consume: ```mermaid flowchart LR B[Browser / Mobile App] U[Unchained EngineGraphQL] D[(MongoDB)] B -->|cookies + headers| U U --> D ``` ## Setting Up GraphQL Client ### Apollo Client (Recommended) ```bash npm install @apollo/client graphql ``` ```typescript // lib/apollo-client.ts const httpLink = new HttpLink({ uri: process.env.NEXT_PUBLIC_UNCHAINED_URL || 'http://localhost:4010/graphql', credentials: 'same-origin', // Additional fetch() options like `credentials` or `headers` }); export const client = new ApolloClient({ link: httpLink, cache: new InMemoryCache(), }); ``` ## Core Queries ### Fetch Products ```graphql query Products($limit: Int, $offset: Int) { products(limit: $limit, offset: $offset) { _id status tags texts { _id title description slug } media { _id file { url } } ... on SimpleProduct { simulatedPrice(currencyCode: "CHF") { amount } } } } ``` ### Fetch Single Product ```graphql query Product($productId: ID, $slug: String) { product(productId: $productId, slug: $slug) { _id texts { title description slug } media { _id file { url } } ... on SimpleProduct { simulatedPrice(currencyCode: "CHF", quantity: 1) { amount currencyCode } dimensions { weight length width height } } ... on ConfigurableProduct { variations { _id key type options { _id value } } } } } ``` ### Fetch Assortments (Categories) ```graphql query Assortments { assortments { _id isRoot texts { title description slug } children { _id texts { title slug } } searchProducts { products { _id texts { title } ... on SimpleProduct { simulatedPrice(currencyCode: "CHF") { amount currencyCode } } } } } } ``` ### Search Products ```graphql query SearchProducts($queryString: String, $filterQuery: [FilterQueryInput!]) { searchProducts(queryString: $queryString, filterQuery: $filterQuery) { products { _id texts { title } ... on SimpleProduct { simulatedPrice(currencyCode: "CHF") { amount currencyCode } } } filters { filteredProductsCount } } } ``` ## User Management ### Current User ```graphql query Me { me { _id primaryEmail { address } username isGuest profile { displayName address { firstName lastName company addressLine city postalCode countryCode } } cart { _id total { amount currencyCode } } orders { _id orderNumber status ordered } } } ``` ### Update Profile ```graphql mutation UpdateProfile { updateUserProfile( profile: { displayName: "John Doe" address: { firstName: "John" lastName: "Doe" addressLine: "123 Main St" city: "Zurich" postalCode: "8000" countryCode: "CH" } } ) { _id profile { displayName address { city } } } } ``` ## Cart Operations ### Get Cart ```graphql query Cart { me { cart { _id items { _id quantity product { _id texts { title } media { file { url } } } unitPrice { amount currencyCode } total { amount currencyCode } } delivery { _id fee { amount currencyCode } } payment { _id fee { amount currencyCode } } total { amount currencyCode } } } } ``` ### Cart Mutations ```graphql # Add item mutation AddToCart($productId: ID!, $quantity: Int!) { addCartProduct(productId: $productId, quantity: $quantity) { _id } } # Update quantity mutation UpdateQuantity($itemId: ID!, $quantity: Int!) { updateCartItem(itemId: $itemId, quantity: $quantity) { _id } } # Remove item mutation RemoveItem($itemId: ID!) { removeCartItem(itemId: $itemId) { _id } } # Empty cart mutation EmptyCart { emptyCart { _id items { _id } } } ``` ## React Component Examples ### Product List ```tsx function ProductList() { const { data, loading, error } = useQuery(PRODUCTS_QUERY, { variables: { limit: 20, offset: 0 }, }); if (loading) return Loading...; if (error) return Error: {error.message}; return ( {data.products.map((product) => ( ))} ); } function ProductCard({ product }) { const title = product.texts?.title || 'Untitled'; const price = product.simulatedPrice; const image = product.media?.[0]?.file?.url; return ( {image && } {title} {price && ( {formatPrice(price.amount, price.currencyCode)} )} ); } ``` ### Add to Cart Button ```tsx function AddToCartButton({ productId }) { const [addToCart, { loading }] = useMutation(ADD_TO_CART, { refetchQueries: [{ query: GET_CART }], }); const handleClick = async () => { try { await addToCart({ variables: { productId, quantity: 1 }, }); } catch (error) { console.error('Failed to add to cart:', error); } }; return ( ); } ``` ### Cart Component ```tsx function Cart() { const { data, loading } = useQuery(GET_CART); const [updateQuantity] = useMutation(UPDATE_QUANTITY); const [removeItem] = useMutation(REMOVE_ITEM); const cart = data?.me?.cart; if (loading) return Loading cart...; if (!cart?.items?.length) return Your cart is empty; return ( {cart.items.map((item) => ( updateQuantity({ variables: { itemId: item._id, quantity } }) } onRemove={() => removeItem({ variables: { itemId: item._id } }) } /> ))} Subtotal: {formatPrice(cart.itemsTotal?.amount, cart.itemsTotal?.currencyCode)} {cart.delivery?.fee && ( Shipping: {formatPrice(cart.delivery.fee.amount, cart.delivery.fee.currencyCode)} )} Total: {formatPrice(cart.total?.amount, cart.total?.currencyCode)} ); } ``` ## Authentication Flow ### Login Component ```tsx function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [login] = useMutation(LOGIN, { refetchQueries: [{ query: GET_ME }], }); const [loginAsGuest] = useMutation(LOGIN_AS_GUEST, { refetchQueries: [{ query: GET_ME }], }); const handleLogin = async (e) => { e.preventDefault(); try { const { data } = await login({ variables: { email, password }, }); // Token is set as HTTP-only cookie automatically // The _id is the session ID for reference console.log('Logged in, session:', data.loginWithPassword._id); } catch (error) { console.error('Login failed:', error); } }; const handleGuestCheckout = async () => { try { const { data } = await loginAsGuest(); // Token is set as HTTP-only cookie automatically console.log('Guest session:', data.loginAsGuest._id); } catch (error) { console.error('Guest login failed:', error); } }; return (
setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Password" />
); } ``` ## Utility Functions ### Format Price ```typescript export function formatPrice(amount: number, currency: string): string { return new Intl.NumberFormat('de-CH', { style: 'currency', currency, }).format(amount / 100); // Convert from cents } ``` ### Slugify ```typescript export function productUrl(product: { texts?: { slug?: string }; _id: string }): string { const slug = product.texts?.slug || product._id; return `/products/${slug}`; } ``` ## Next.js Integration ### API Route for Server-Side Queries ```typescript // pages/api/products.ts export default async function handler(req, res) { const { data } = await client.query({ query: PRODUCTS_QUERY, variables: { limit: 20 }, }); res.json(data.products); } ``` ### Server-Side Rendering ```typescript // pages/products/[slug].tsx export async function getServerSideProps({ params }) { const { data } = await client.query({ query: PRODUCT_QUERY, variables: { slug: params.slug }, }); if (!data.product) { return { notFound: true }; } return { props: { product: data.product }, }; } ``` ### Static Generation ```typescript // pages/products/[slug].tsx export async function getStaticPaths() { const { data } = await client.query({ query: ALL_PRODUCT_SLUGS }); const paths = data.products.map((product) => ({ params: { slug: product.texts?.slug || product._id }, })); return { paths, fallback: 'blocking' }; } export async function getStaticProps({ params }) { const { data } = await client.query({ query: PRODUCT_QUERY, variables: { slug: params.slug }, }); return { props: { product: data.product }, revalidate: 60, // Regenerate every 60 seconds }; } ``` ## Related - [Checkout Implementation](./checkout-implementation) - Complete checkout flow - [Authentication](../concepts/authentication) - Auth patterns --- ## Bulk Import This guide covers importing large datasets from external systems like PIM (Product Information Management) or ERP (Enterprise Resource Planning) into Unchained Engine. ## Overview The Bulk Import API is designed for high-volume data synchronization: ```mermaid flowchart LR PIM[PIM/ERP System] --> BI[Bulk ImportWork Queue] --> DB[(Unchained DBMongoDB)] ``` ### Key Features - **Cloud Native**: Background processing on dedicated worker instances - **Transparent Process**: Results stored on work items for queryable success/failure - **Error Reporting**: Sync issues reported via email to a central address - **Performance**: MongoDB bulk operations and intelligent asset caching - **Push-Based**: Immediate representation of changes ## Import Methods ### GraphQL Method For smaller imports, use the GraphQL mutation: ```graphql mutation BulkImport { addWork( type: BULK_IMPORT input: { events: [ { entity: "PRODUCT" operation: "CREATE" payload: "{}" } ] } ) { _id status } } ``` ### REST Endpoint For large imports (5K+ entities or >16MB), use the REST endpoint: ```bash curl -X POST \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ --data-binary @products.json \ https://your-engine.com/bulk-import ``` ## Event Structure Every event consists of three parts: ```json { "entity": "ENTITY_TYPE", "operation": "OPERATION_TYPE", "payload": { ... } } ``` ### Supported Entities | Entity | Description | |--------|-------------| | `PRODUCT` | Products (simple, configurable, bundle, plan) | | `ASSORTMENT` | Categories and collections | | `FILTER` | Product filters and facets | ### Supported Operations | Operation | Description | |-----------|-------------| | `CREATE` | Create new entity | | `UPDATE` | Update existing entity | | `REMOVE` | Delete entity | ## Import Options Pass options as query parameters (REST) or in the input object (GraphQL): | Option | Description | |--------|-------------| | `createShouldUpsertIfIDExists` | CREATE updates if entity exists | | `updateShouldUpsertIfIDNotExists` | UPDATE creates if entity missing | | `skipCacheInvalidation` | Skip filter/assortment cache updates | ```bash # REST with options curl -X POST \ "https://your-engine.com/bulk-import?createShouldUpsertIfIDExists=true" \ --data-binary @products.json ``` ## Product Import ```json { "entity": "PRODUCT", "operation": "CREATE", "payload": { "_id": "configurable-product", "specification": { "type": "CONFIGURABLE_PRODUCT", "published": "2024-01-01T00:00:00Z", "variationResolvers": [ { "vector": { "color": "red", "size": "M" }, "productId": "variant-red-m" }, { "vector": { "color": "blue", "size": "M" }, "productId": "variant-blue-m" } ], "content": { "en": { "title": "Configurable T-Shirt", "slug": "configurable-t-shirt" } } }, "variations": [ { "key": "color", "type": "COLOR", "options": [ { "value": "red", "content": { "en": { "title": "Red" } } }, { "value": "blue", "content": { "en": { "title": "Blue" } } } ], "content": { "en": { "title": "Color" } } }, { "key": "size", "type": "TEXT", "options": [ { "value": "M", "content": { "en": { "title": "Medium" } } } ], "content": { "en": { "title": "Size" } } } ] } } ``` ## Assortment Import ### Create Category Hierarchy ```json { "entity": "ASSORTMENT", "operation": "CREATE", "payload": { "_id": "root-category", "specification": { "isActive": true, "isRoot": true, "tags": ["main-nav"], "content": { "en": { "title": "All Products", "slug": "all-products", "description": "Browse all products" } } }, "children": [ { "assortmentId": "electronics", "tags": [] }, { "assortmentId": "clothing", "tags": [] } ], "products": [ { "productId": "featured-product", "tags": ["featured"] } ], "filters": [ { "filterId": "brand-filter" } ], "media": [ { "asset": { "url": "https://example.com/category-banner.jpg" }, "tags": ["banner"], "content": { "en": { "title": "Category Banner" } } } ] } } ``` ## Filter Import ### Create Product Filter ```json { "entity": "FILTER", "operation": "CREATE", "payload": { "_id": "brand-filter", "specification": { "key": "brand", "isActive": true, "type": "SINGLE_CHOICE", "options": [ { "value": "nike", "content": { "en": { "title": "Nike" }, "de": { "title": "Nike" } } }, { "value": "adidas", "content": { "en": { "title": "Adidas" }, "de": { "title": "Adidas" } } } ], "content": { "en": { "title": "Brand", "subtitle": "Filter by brand" } } } } } ``` ### Filter Types | Type | Description | |------|-------------| | `SINGLE_CHOICE` | Select one option | | `MULTI_CHOICE` | Select multiple options | | `RANGE` | Numeric range (price, weight) | | `SWITCH` | Boolean toggle | ## Custom Import Handlers Create custom handlers for specialized import needs: ```typescript const customHandlers: Record = { INVENTORY: { UPDATE: async function updateInventory( payload: { sku: string; quantity: number }, options: { logger?: any }, unchainedAPI: UnchainedCore ) { const { sku, quantity } = payload; await unchainedAPI.modules.warehousing.updateStock(sku, quantity); return { entity: 'INVENTORY', operation: 'UPDATE', success: true, }; }, }, }; // Register handlers await startPlatform({ bulkImporter: { handlers: customHandlers, }, }); ``` ### Usage ```json { "entity": "INVENTORY", "operation": "UPDATE", "payload": { "sku": "SKU-123", "quantity": 50 } } ``` ## Best Practices ### 1. Batch Events Send multiple events in a single request: ```json { "events": [ { "entity": "PRODUCT", "operation": "CREATE", "payload": { ... } }, { "entity": "PRODUCT", "operation": "CREATE", "payload": { ... } }, { "entity": "PRODUCT", "operation": "CREATE", "payload": { ... } } ] } ``` ### 2. Use REST for Large Imports Switch to REST endpoint when: - More than 5,000 entities - JSON payload exceeds 16MB ### 3. Order Dependencies Import in the correct order: 1. Filters (referenced by assortments) 2. Products (referenced by assortments) 3. Assortments (may reference filters and products) ### 4. Idempotent Imports Use `createShouldUpsertIfIDExists` for safe re-runs: ```bash curl -X POST \ "https://your-engine.com/bulk-import?createShouldUpsertIfIDExists=true" \ --data-binary @products.json ``` ### 5. Skip Cache for Availability Updates For inventory-only updates, skip cache invalidation: ```bash curl -X POST \ "https://your-engine.com/bulk-import?skipCacheInvalidation=true" \ --data-binary @inventory.json ``` ## Monitoring Imports ### Query Import Status ```graphql query ImportJobs { workQueue(types: [BULK_IMPORT], limit: 10) { _id type status started finished result error } } ``` ### Import Statuses | Status | Description | |--------|-------------| | `NEW` | Queued for processing | | `ALLOCATED` | Being processed | | `SUCCESS` | Completed successfully | | `FAILED` | Failed with error | ## Sync Service Example For systems requiring pull-based sync: ```typescript const PIMSyncWorker = { key: 'shop.example.worker.pim-sync', label: 'PIM Synchronization', version: '1.0.0', type: 'PIM_SYNC', external: true, maxParallelAllocations: 1, async doWork(input, unchainedAPI) { const { lastSyncDate } = input; // Fetch changed products from PIM const products = await fetchPIMProducts({ since: lastSyncDate }); // Transform to bulk import format const events = products.map(product => ({ entity: 'PRODUCT', operation: 'UPDATE', payload: transformProduct(product), })); // Queue bulk import await unchainedAPI.modules.worker.addWork({ type: 'BULK_IMPORT', input: { events, createShouldUpsertIfIDExists: true, }, }); return { success: true, result: { synced: events.length } }; }, }; WorkerDirector.registerAdapter(PIMSyncWorker); // Schedule hourly sync WorkerDirector.configureAutoscheduling({ type: 'PIM_SYNC', input: {}, schedule: '0 * * * *', }); ``` ## Related - [Custom Modules](../extend/custom-modules) - Create custom modules for sync logic - [Worker Module](../platform-configuration/modules/worker) - Background job processing - [Events](../extend/events/) - React to import events --- ## Checkout Implementation This guide explains how to implement a **safe, production-ready checkout flow** using Unchained Engine. Unchained checkout is not a single mutation. It is a **state machine** with **locking, payment signing, stock validation and async confirmation**. --- ## Checkout State Machine ```mermaid flowchart TD A[Cart] --> B[Delivery Selected] B --> C[Payment Selected] C --> D[Payment Signed] D --> E[Checkout Started] E -->|async| F[PENDING] F -->|webhook| G[CONFIRMED] F -->|failure| H[REJECTED] ``` ## Step 1: User Authentication Before adding items to a cart, the user must be authenticated (as guest or registered). ### Guest Checkout ```graphql mutation LoginAsGuest { loginAsGuest { _id tokenExpires user { _id isGuest } } } ``` The session token is set as an HTTP-only cookie automatically. For subsequent requests, ensure cookies are sent with your requests. ### Registered User Login ```graphql mutation Login { loginWithPassword(email: "user@example.com", password: "password") { _id tokenExpires user { _id username } } } ``` ## Step 2: Add Products to Cart Add products to the cart. The cart is created automatically on first mutation. ### Add Simple Product ```graphql mutation AddToCart { addCartProduct(productId: "product-123", quantity: 2) { _id quantity product { _id texts { title } } unitPrice { amount currencyCode } total { amount currencyCode } order { _id total { amount currencyCode } } } } ``` ### Add Configurable Product For products with variations or configurations: ```graphql mutation AddConfiguredProduct { addCartProduct( productId: "configurable-product-123" quantity: 1 configuration: [ { key: "size", value: "L" } { key: "color", value: "blue" } ] ) { _id configuration { key value } } } ``` ### Update Quantity ```graphql mutation UpdateQuantity { updateCartItem(itemId: "cart-item-123", quantity: 3) { _id quantity } } ``` This recalculates tax, shipping and discounts. Always refetch the cart after this. ### Remove Item ```graphql mutation RemoveItem { removeCartItem(itemId: "cart-item-123") { _id } } ``` ## Step 3: Set Delivery & Payment ### Get Available Providers Fetch both delivery and payment providers in a single query: ```graphql query GetProviders { me { cart { _id supportedDeliveryProviders { _id type interface { _id label } simulatedPrice { amount currencyCode } } supportedPaymentProviders { _id type interface { _id label } } } } } ``` ### Set Both Providers Set delivery and payment providers in one mutation: ```graphql mutation SetProviders { updateCart( deliveryProviderId: "delivery-provider-123" paymentProviderId: "payment-provider-123" ) { _id delivery { _id provider { _id type interface { label } } fee { amount currencyCode } } } } ``` ### Set Delivery Address ```graphql mutation SetDeliveryAddress { updateCartDeliveryShipping( deliveryProviderId: "delivery-provider-123" address: { firstName: "John" lastName: "Doe" company: "ACME Inc" addressLine: "123 Main St" addressLine2: "Apt 4" postalCode: "12345" city: "Zurich" countryCode: "CH" } ) { _id delivery { ... on OrderDeliveryShipping { _id address { firstName lastName city countryCode } } } } } ``` ### Set Pickup Location (for PICKUP delivery) ```graphql mutation SetPickupLocation { updateCartDeliveryPickUp( deliveryProviderId: "delivery-provider-123" orderPickUpLocationId: "pickup-location-123" ) { _id delivery { ... on OrderDeliveryPickUp { _id activePickUpLocation { _id name address { addressLine city } } } } } } ``` ### Initialize Payment (Sign) For client-side payment SDKs (Stripe, PayPal), get the client token before checkout: ```graphql mutation SignPayment { signPaymentProviderForCheckout( orderPaymentId: "order-payment-123" ) } ``` The returned value is used to initialize the payment SDK on the client. ## Step 4: Review Cart Get the complete cart with all pricing: ```graphql query ReviewCart { me { cart { _id items { _id quantity product { texts { title } } total { amount currencyCode } } delivery { ... on OrderDeliveryShipping { address { firstName lastName addressLine city countryCode } } fee { amount currencyCode } } payment { provider { interface { label } } fee { amount currencyCode } } discounts { total { amount currencyCode } code } total { amount currencyCode } } } } ``` ### Apply Discount Code ```graphql mutation ApplyDiscount { addCartDiscount(code: "SAVE10") { _id code total { amount currencyCode } order { _id total { amount currencyCode } } } } ``` ### Remove Discount Code ```graphql mutation RemoveDiscount { removeCartDiscount(discountId: "discount-123") { _id order { _id discounts { _id } } } } ``` ## Step 5: Checkout ### Standard Checkout ```graphql mutation Checkout { checkoutCart { _id status orderNumber ordered payment { status } delivery { status } total { amount currencyCode } } } ``` ### Checkout with Payment Context For payment providers that need additional data: ```graphql mutation CheckoutWithPayment { checkoutCart( paymentContext: { paymentIntentId: "pi_xxx" # From Stripe } ) { _id status orderNumber } } ``` ## Step 6: Post-Checkout ### Check Order Status ```graphql query OrderStatus { order(orderId: "order-123") { _id status orderNumber payment { status } delivery { status } } } ``` ### Order Statuses Explained | Status | Meaning | |--------|---------| | `null` | Cart (not checked out) | | `PENDING` | Checked out, awaiting payment | | `CONFIRMED` | Payment confirmed, ready for delivery | | `FULFILLED` | Order delivered and complete | | `REJECTED` | Order cancelled | ## Complete Example: React Implementation ```tsx function Checkout() { const [step, setStep] = useState('cart'); // Get cart data const { data: cartData, refetch } = useQuery(GET_CART); const cart = cartData?.me?.cart; // Mutations const [addToCart] = useMutation(ADD_TO_CART); const [updateCart] = useMutation(UPDATE_CART); const [setDeliveryAddress] = useMutation(SET_DELIVERY_ADDRESS); const [signPayment] = useMutation(SIGN_PAYMENT); const [checkout] = useMutation(CHECKOUT); const handleAddProduct = async (productId: string, quantity: number) => { await addToCart({ variables: { productId, quantity } }); refetch(); }; const handleSetProviders = async ( deliveryProviderId: string, paymentProviderId: string, address: Address ) => { // Set both providers in one call await updateCart({ variables: { deliveryProviderId, paymentProviderId }, }); await setDeliveryAddress({ variables: { deliveryProviderId, address }, }); refetch(); setStep('review'); }; const handleCheckout = async () => { try { // Get payment client token if needed const { data: signData } = await signPayment({ variables: { orderPaymentId: cart.payment._id }, }); // Initialize payment SDK (e.g., Stripe) // await stripe.confirmPayment(signData.signPaymentProviderForCheckout) // Complete checkout const { data: orderData } = await checkout(); if (orderData.checkoutCart.status === 'CONFIRMED') { setStep('confirmation'); } else if (orderData.checkoutCart.status === 'PENDING') { // Payment pending - show waiting message setStep('pending'); } } catch (error) { console.error('Checkout failed:', error); } }; return ( {step === 'cart' && ( setStep('providers')} /> )} {step === 'providers' && ( )} {step === 'review' && ( )} {step === 'confirmation' && ( )} ); } ``` ## Error Handling ### Common Checkout Errors | Error | Cause | Solution | |-------|-------|----------| | `NoCartItems` | Empty cart | Ensure items are added | | `NoDeliveryProvider` | Delivery not set | Set delivery provider | | `NoPaymentProvider` | Payment not set | Set payment provider | | `PaymentDeclined` | Payment failed | Show error, retry payment | | `ProductNotActive` | Product unavailable | Remove item from cart | ### Handling Payment Errors ```graphql mutation CheckoutWithErrorHandling { checkoutCart { _id status } } ``` If the mutation throws, catch and display the error: ```typescript try { await checkout(); } catch (error) { if (error.graphQLErrors) { const code = error.graphQLErrors[0]?.extensions?.code; switch (code) { case 'PAYMENT_DECLINED': showError('Payment was declined. Please try another method.'); break; case 'INSUFFICIENT_STOCK': showError('Some items are no longer available.'); refetch(); // Refresh cart break; default: showError('Checkout failed. Please try again.'); } } } ``` ## Webhooks for Async Payments Many payment providers confirm payments asynchronously via webhooks: ```typescript // Express webhook handler app.post('/webhooks/stripe', async (req, res) => { const event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET ); if (event.type === 'payment_intent.succeeded') { const { orderId } = event.data.object.metadata; // The order will automatically transition to CONFIRMED // via the payment adapter's charge() method } res.json({ received: true }); }); ``` ## Related - [Order Lifecycle](../concepts/order-lifecycle.md) - Understanding order states - [Payment Plugins](../plugins/payment/stripe.md) - Payment adapters - [Delivery Plugins](../plugins/) - Delivery adapters --- ## Contributing This guide covers how to set up a development environment and contribute to Unchained Engine. ## Prerequisites - **Node.js 22+** (25 recommended, see `.nvmrc`) - **MongoDB** (or MongoDB Memory Server for testing) - **npm** (uses npm workspaces) ## Getting Started ```bash # Clone the repository git clone https://github.com/unchainedshop/unchained.git cd unchained # Install dependencies npm install # Start development npm run dev ``` `npm run dev` starts: - The kitchensink example on port 3000 - Admin UI - Watch mode for all packages ## Monorepo Structure ``` unchained/ β”œβ”€β”€ packages/ β”‚ β”œβ”€β”€ api/ # GraphQL API, Express/Fastify adapters, MCP server β”‚ β”œβ”€β”€ core/ # Business logic coordination β”‚ β”œβ”€β”€ core-*/ # Domain modules (orders, products, users, etc.) β”‚ β”œβ”€β”€ events/ # Event system β”‚ β”œβ”€β”€ logger/ # Logging utilities β”‚ β”œβ”€β”€ mongodb/ # MongoDB integration β”‚ β”œβ”€β”€ platform/ # Main entry point, combines all packages β”‚ β”œβ”€β”€ plugins/ # Plugin adapters (payment, delivery, pricing, etc.) β”‚ β”œβ”€β”€ roles/ # RBAC permission system β”‚ β”œβ”€β”€ ticketing/ # Event ticketing β”‚ └── utils/ # Shared utilities β”œβ”€β”€ examples/ β”‚ β”œβ”€β”€ kitchensink/ # Full-featured Fastify example (default) β”‚ β”œβ”€β”€ kitchensink-express/ # Express alternative β”‚ β”œβ”€β”€ minimal/ # Minimal setup example β”‚ β”œβ”€β”€ oidc/ # OIDC authentication example β”‚ └── ticketing/ # Ticketing example β”œβ”€β”€ tests/ # Integration tests └── docs/ # Documentation (Docusaurus) ``` ## Package Hierarchy ``` platform β†’ api β†’ core β†’ core-* β†’ infrastructure (mongodb, events, logger, utils, roles) ``` Higher-level packages should only import from lower-level packages. Specifically: - **DO NOT** import `@unchainedshop/mongodb` outside of `core-*` and infrastructure packages - The API layer should use module APIs exposed by core packages, not direct MongoDB access ## Development Commands ```bash npm run dev # Start development with hot-reload npm run build # Clean and rebuild all packages npm run dev:watch # Watch mode for TypeScript compilation npm run lint # Lint and fix code (ESLint + Prettier) npm run test # Run all tests npm run test:run:unit # Unit tests only npm run test:run:integration # Integration tests ``` ## Code Conventions ### Import Style Use `.ts` extensions for relative imports, no extensions for package imports: ```typescript // Relative imports - MUST include .ts extension // Package imports - no extension ``` ### TypeScript - Module system: `NodeNext` (native ESM) - `allowImportingTsExtensions: true` - No compilation needed for development: `node --watch src/file.ts` ### Types - Place types in the most relevant implementation file, not separate `types.ts` files - Exception: external API contract types may use a dedicated types file ## Running Tests ```bash # All tests npm run test # Single unit test node --test packages/core-orders/src/orders.test.ts # Single integration test (from monorepo root) node --no-warnings \ --env-file .env.tests \ --env-file-if-exists=.env \ --test-isolation=none \ --test-force-exit \ --test-global-setup=tests/helpers.js \ --test \ --test-concurrency=1 \ tests/path/to/test.ts ``` ## Pull Requests 1. Fork the repository 2. Create a feature branch from `master` 3. Make your changes following the code conventions 4. Ensure tests pass: `npm run test` 5. Ensure lint passes: `npm run lint` 6. Submit a pull request against `master` ## Getting Help - [GitHub Discussions](https://github.com/unchainedshop/unchained/discussions) - [GitHub Issues](https://github.com/unchainedshop/unchained/issues) - [support@unchained.shop](mailto:support@unchained.shop) for enterprise support --- ## Custom Pricing This guide covers implementing custom pricing logic in Unchained Engine using pricing adapters. ## Overview Unchained uses a pricing pipeline where multiple adapters can contribute to the final price: ```mermaid flowchart LR BP[Base PriceAdapter] --> TA[TaxAdapter] --> DA[DiscountAdapter] --> FP[Final Price] ``` ### Key Principles **Determinism:** All pricing must produce the same result for the same input. Avoid using real-time external data after checkout begins. If you fetch external data, store it in meta for reproducibility. **Immutability after checkout:** Once checkoutCart is executed, prices are frozen. Adapters cannot change finalized orders. **Net vs gross:** | Flag | Meaning | | ------------------ | --------------------------- | | `isNetPrice=true` | Tax will be added later | | `isNetPrice=false` | Amount already includes tax | **Currency awareness:** Adapters must respect `currencyCode` in context. Do not assume base currency. Use `modules.currencies.round(amount, currencyCode)` for correct rounding. ## Prerequisites - Node.js 22+ - Running Unchained Engine instance - Basic TypeScript knowledge ## Creating a Product Pricing Adapter ### Basic Structure ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; class CustomPricingAdapter extends ProductPricingAdapter { // Unique identifier static key = 'shop.example.pricing.custom'; // Display name in admin static label = 'Custom Pricing Adapter'; // Version for tracking static version = '1.0.0'; // Execution order (lower = earlier) static orderIndex = 10; // When to activate this adapter static isActivatedFor({ product, country, currency }) { return true; // Activate for all products } // Calculate pricing adjustments async calculate() { // Add pricing items this.result.addItem({ amount: 100, // Amount in cents isTaxable: true, isNetPrice: true, meta: { adapter: this.constructor.key }, }); // Always call super.calculate() at the end return super.calculate(); } } // Register the adapter ProductPricingDirector.registerAdapter(CustomPricingAdapter); ``` ### Context Available The adapter has access to context information: ```typescript async calculate() { const { product, // The product being priced quantity, // Quantity requested currencyCode, // Target currency countryCode, // Target country user, // Current user (if logged in) discounts, // Applied discount codes modules, // All Unchained modules } = this.context; // Your pricing logic here } ``` ## Example: Weather-Based Pricing A fun example that adjusts sausage prices based on outdoor temperature: ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; const SAUSAGE_TAG = 'sausage'; const TEMPERATURE_THRESHOLD = 20; // Celsius class WeatherBasedPricingAdapter extends ProductPricingAdapter { static key = 'shop.example.pricing.weather-based'; static label = 'Weather-Based Sausage Pricing'; static version = '1.0.0'; static orderIndex = 5; // Only activate for products tagged with "sausage" static isActivatedFor({ product }) { return product.tags?.includes(SAUSAGE_TAG); } async calculate() { const { quantity, currencyCode } = this.context; try { // Fetch current weather const weather = await this.fetchWeather('Zurich'); if (weather.temperature > TEMPERATURE_THRESHOLD) { // BBQ season! Increase price this.result.addItem({ amount: 100 * quantity, // +1 CHF per item isTaxable: true, isNetPrice: true, meta: { adapter: this.constructor.key, reason: 'bbq-season-surcharge', temperature: weather.temperature, }, }); } } catch (error) { console.error('Weather pricing failed:', error); // Gracefully continue without adjustment } return super.calculate(); } private async fetchWeather(city: string): Promise<{ temperature: number }> { const response = await fetch( `https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${city}` ); const data = await response.json(); return { temperature: data.current.temp_c }; } } ProductPricingDirector.registerAdapter(WeatherBasedPricingAdapter); ``` ## Example: Volume Discounts Implement quantity-based discounts: ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; const VOLUME_TIERS = [ { minQuantity: 100, discount: 0.20 }, // 20% off for 100+ { minQuantity: 50, discount: 0.15 }, // 15% off for 50+ { minQuantity: 20, discount: 0.10 }, // 10% off for 20+ { minQuantity: 10, discount: 0.05 }, // 5% off for 10+ ]; class VolumeDiscountAdapter extends ProductPricingAdapter { static key = 'shop.example.pricing.volume-discount'; static label = 'Volume Discount'; static version = '1.0.0'; static orderIndex = 20; // Run after base price static isActivatedFor({ product }) { // Only for products marked for volume discounts return product.meta?.allowVolumeDiscount === true; } async calculate() { const { quantity } = this.context; // Find applicable tier const tier = VOLUME_TIERS.find(t => quantity >= t.minQuantity); if (tier) { // Get current subtotal const subtotal = this.calculation.sum(); // Apply percentage discount const discountAmount = Math.round(subtotal * tier.discount); this.result.addItem({ amount: -discountAmount, // Negative for discount isTaxable: true, isNetPrice: true, meta: { adapter: this.constructor.key, tier: tier.minQuantity, discountPercent: tier.discount * 100, }, }); } return super.calculate(); } } ProductPricingDirector.registerAdapter(VolumeDiscountAdapter); ``` ## Example: Customer-Specific Pricing Different prices for B2B customers: ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; class B2BPricingAdapter extends ProductPricingAdapter { static key = 'shop.example.pricing.b2b'; static label = 'B2B Customer Pricing'; static version = '1.0.0'; static orderIndex = 3; // Run early static isActivatedFor({ product, context }) { // Only for logged-in B2B customers return context.user?.tags?.includes('b2b'); } async calculate() { const { product, user, modules } = this.context; // Check for customer-specific price const customerPrice = await this.getCustomerPrice(product, user); if (customerPrice) { // Replace base price with customer price this.result.resetCalculation(); this.result.addItem({ amount: customerPrice.amount, isTaxable: customerPrice.isTaxable, isNetPrice: true, meta: { adapter: this.constructor.key, priceListId: customerPrice.priceListId, }, }); } return super.calculate(); } private async getCustomerPrice(product, user) { // Look up price from customer-specific price list const priceList = user.meta?.priceListId; if (!priceList) return null; // Your price list lookup logic here return null; } } ProductPricingDirector.registerAdapter(B2BPricingAdapter); ``` ## Example: Time-Based Pricing Happy hour or flash sale pricing: ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; class HappyHourPricingAdapter extends ProductPricingAdapter { static key = 'shop.example.pricing.happy-hour'; static label = 'Happy Hour Pricing'; static version = '1.0.0'; static orderIndex = 15; static isActivatedFor({ product }) { return product.tags?.includes('happy-hour-eligible'); } async calculate() { const now = new Date(); const hour = now.getHours(); // Happy hour: 16:00 - 18:00 if (hour >= 16 && hour < 18) { const subtotal = this.calculation.sum(); const discount = Math.round(subtotal * 0.25); // 25% off this.result.addItem({ amount: -discount, isTaxable: true, isNetPrice: true, meta: { adapter: this.constructor.key, reason: 'happy-hour', validUntil: new Date(now.setHours(18, 0, 0, 0)).toISOString(), }, }); } return super.calculate(); } } ProductPricingDirector.registerAdapter(HappyHourPricingAdapter); ``` ## Other Pricing Adapter Types ### Order Pricing Adjust order-level pricing (shipping discounts, order minimums): ```typescript OrderPricingAdapter, OrderPricingDirector, } from '@unchainedshop/core-pricing'; class FreeShippingAdapter extends OrderPricingAdapter { static key = 'shop.example.pricing.free-shipping'; static label = 'Free Shipping Over 100'; static version = '1.0.0'; static orderIndex = 10; static isActivatedFor() { return true; } async calculate() { const { order } = this.context; const itemsTotal = order.calculation?.items || 0; // Free shipping for orders over 100 if (itemsTotal >= 10000) { // 100.00 in cents const shippingCost = this.calculation.sum({ category: 'DELIVERY' }); if (shippingCost > 0) { this.result.addItem({ amount: -shippingCost, category: 'DELIVERY', isTaxable: false, isNetPrice: true, meta: { adapter: this.constructor.key, reason: 'free-shipping-threshold', }, }); } } return super.calculate(); } } OrderPricingDirector.registerAdapter(FreeShippingAdapter); ``` ### Delivery Pricing Custom delivery cost calculations: ```typescript DeliveryPricingAdapter, DeliveryPricingDirector, } from '@unchainedshop/core-pricing'; class WeightBasedDeliveryAdapter extends DeliveryPricingAdapter { static key = 'shop.example.pricing.weight-delivery'; static label = 'Weight-Based Delivery'; static version = '1.0.0'; static orderIndex = 5; static isActivatedFor({ provider }) { return provider.type === 'SHIPPING'; } async calculate() { const { order, modules } = this.context; // Calculate total weight let totalWeight = 0; for (const item of order.items) { const product = await modules.products.findProduct({ productId: item.productId }); totalWeight += (product.warehousing?.dimensions?.weightInGram || 0) * item.quantity; } // Price tiers by weight let deliveryCost = 500; // Base 5.00 if (totalWeight > 5000) deliveryCost = 1500; // 15.00 for 5kg+ else if (totalWeight > 2000) deliveryCost = 1000; // 10.00 for 2kg+ else if (totalWeight > 1000) deliveryCost = 750; // 7.50 for 1kg+ this.result.addItem({ amount: deliveryCost, isTaxable: true, isNetPrice: true, meta: { adapter: this.constructor.key, totalWeightGrams: totalWeight, }, }); return super.calculate(); } } DeliveryPricingDirector.registerAdapter(WeightBasedDeliveryAdapter); ``` ### Payment Pricing Payment method surcharges or discounts: ```typescript PaymentPricingAdapter, PaymentPricingDirector, } from '@unchainedshop/core-pricing'; class CashDiscountAdapter extends PaymentPricingAdapter { static key = 'shop.example.pricing.cash-discount'; static label = 'Cash Payment Discount'; static version = '1.0.0'; static orderIndex = 5; static isActivatedFor({ provider }) { return provider.adapterKey === 'shop.unchained.payment.invoice'; } async calculate() { const { order } = this.context; const orderTotal = order.calculation?.total || 0; // 2% discount for bank transfer const discount = Math.round(orderTotal * 0.02); this.result.addItem({ amount: -discount, isTaxable: false, isNetPrice: true, meta: { adapter: this.constructor.key, reason: 'cash-discount', }, }); return super.calculate(); } } PaymentPricingDirector.registerAdapter(CashDiscountAdapter); ``` ## Registration Register your adapter by importing it in your boot file: ```typescript // boot.ts ``` ## Testing Your Adapter Use the GraphQL playground to test: ```graphql query TestPricing { product(productId: "your-product-id") { ... on SimpleProduct { simulatedPrice(quantity: 10) { amount currencyCode isTaxable isNetPrice } } } } ``` ## Best Practices ### 1. Order Index Strategy ```typescript // Suggested order indices: // 0-5: Base price, currency conversion // 5-10: Customer-specific pricing // 10-20: Product-level discounts // 20-30: Tax calculations // 30+: Final adjustments ``` ### 2. Always Call Super ```typescript async calculate() { // Your logic here return super.calculate(); // Don't forget this! } ``` ### 3. Use Meta for Transparency ```typescript this.result.addItem({ amount: discountAmount, meta: { adapter: this.constructor.key, reason: 'volume-discount', appliedTier: '20+', originalAmount: baseAmount, }, }); ``` ### 4. Handle Errors Gracefully ```typescript async calculate() { try { const externalData = await fetchExternalPricing(); // Apply pricing } catch (error) { console.error('External pricing failed:', error); // Continue without failing the entire pricing } return super.calculate(); } ``` ### 5. Cache External Calls ```typescript const priceCache = new Map(); async calculate() { const cacheKey = `${this.context.product._id}-${this.context.currencyCode}`; if (!priceCache.has(cacheKey)) { const price = await this.fetchExternalPrice(); priceCache.set(cacheKey, { price, expires: Date.now() + 60000 }); } const cached = priceCache.get(cacheKey); if (cached.expires > Date.now()) { // Use cached price } } ``` ## Related - [Pricing System](../concepts/pricing-system) - Pricing architecture overview - [Order Discounts](../extend/pricing/order-discounts) - Discount system - [Product Pricing](../extend/pricing/product-pricing) - Product pricing details --- ## File Uploads This guide covers configuring and using file uploads in Unchained Engine with MinIO or any S3-compatible storage. ## Overview Unchained Engine uses pre-signed URLs for secure, efficient file uploads directly to storage: ```mermaid sequenceDiagram participant C as Client participant A as Unchained API participant S as MinIO / S3 C->>A: 1. Request pre-signed URL A->>C: 2. Return signed URL C->>S: 3. Direct PUT upload S-->>A: Webhook notification A->>C: 4. Confirm upload ``` ## MinIO Setup ### Installation Download and install MinIO for your platform from the [official website](https://min.io/download), or use Docker: ```bash docker run -p 9000:9000 -p 9001:9001 \ -e "MINIO_ROOT_USER=minioadmin" \ -e "MINIO_ROOT_PASSWORD=minioadmin" \ minio/minio server /data --console-address ":9001" ``` ### Configuration Set the following environment variables: ```bash # MinIO Server MINIO_ENDPOINT=http://localhost:9000 # Credentials MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin # Storage MINIO_BUCKET_NAME=unchained-files # Webhook (for automatic confirmation) MINIO_WEBHOOK_AUTH_TOKEN=your-secure-jwt-token ``` ### Bucket Setup Create a bucket and set public read access: ```bash # Using MinIO Client (mc) mc alias set local http://localhost:9000 minioadmin minioadmin mc mb local/unchained-files mc anonymous set download local/unchained-files ``` ## Upload Flow The recommended approach uses pre-signed URLs for secure, direct-to-storage uploads: ### Step 1: Get Pre-signed URL Request a pre-signed upload URL from the API: ```graphql mutation PrepareUpload($mediaName: String!, $productId: ID!) { prepareProductMediaUpload(mediaName: $mediaName, productId: $productId) { _id putURL expires } } ``` Response: ```json { "data": { "prepareProductMediaUpload": { "_id": "media-ticket-123", "putURL": "https://minio.example.com/bucket/file.jpg?X-Amz-Signature=...", "expires": "2024-01-01T01:00:00Z" } } } ``` ### Step 2: Upload Directly to Storage Upload the file directly to the pre-signed URL: ```typescript async function uploadFile(file: File, putURL: string) { const response = await fetch(putURL, { method: 'PUT', body: file, headers: { 'Content-Type': file.type, }, }); if (!response.ok) { throw new Error('Upload failed'); } } ``` ### Step 3: Confirm Upload **Option A: Manual Confirmation** ```graphql mutation ConfirmUpload($mediaUploadTicketId: ID!, $size: Int!, $type: String!) { confirmMediaUpload( mediaUploadTicketId: $mediaUploadTicketId size: $size type: $type ) { _id name type size url } } ``` **Option B: Webhook Confirmation (Recommended)** Configure MinIO to automatically confirm uploads via webhook. ## Webhook Setup ### Enable Webhook Handler Import the webhook handler in your boot file: ```typescript // For Express // For Fastify ``` ### Configure MinIO Webhook 1. Create a webhook endpoint in MinIO console or via mc: ```bash # Set webhook endpoint mc admin config set local notify_webhook:unchained \ endpoint="https://your-engine.com/minio" \ auth_token="your-secure-jwt-token" # Restart MinIO to apply mc admin service restart local # Configure bucket notification mc event add local/unchained-files arn:minio:sqs::unchained:webhook \ --event "put" ``` 2. Ensure `MINIO_WEBHOOK_AUTH_TOKEN` matches the `auth_token` in the webhook configuration. ### Webhook Flow ``` 1. Client requests pre-signed URL from API 2. Client uploads directly to MinIO using PUT 3. MinIO sends webhook notification to /minio endpoint 4. Unchained confirms the upload automatically ``` ## Frontend Implementation ### React Upload Component ```tsx const PREPARE_UPLOAD = gql` mutation PrepareUpload($mediaName: String!, $productId: ID!) { prepareProductMediaUpload(mediaName: $mediaName, productId: $productId) { _id putURL expires } } `; const CONFIRM_UPLOAD = gql` mutation ConfirmUpload($mediaUploadTicketId: ID!, $size: Int!, $type: String!) { confirmMediaUpload( mediaUploadTicketId: $mediaUploadTicketId size: $size type: $type ) { _id url } } `; function ProductMediaUpload({ productId }: { productId: string }) { const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const [prepareUpload] = useMutation(PREPARE_UPLOAD); const [confirmUpload] = useMutation(CONFIRM_UPLOAD); const handleFileSelect = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; setUploading(true); setProgress(0); try { // Step 1: Get pre-signed URL const { data } = await prepareUpload({ variables: { mediaName: file.name, productId, }, }); const { _id: ticketId, putURL } = data.prepareProductMediaUpload; // Step 2: Upload to MinIO await uploadWithProgress(file, putURL, setProgress); // Step 3: Confirm upload (skip if using webhook) await confirmUpload({ variables: { mediaUploadTicketId: ticketId, size: file.size, type: file.type, }, }); alert('Upload complete!'); } catch (error) { console.error('Upload failed:', error); alert('Upload failed'); } finally { setUploading(false); } }; return ( {uploading && ( {progress}% )} ); } async function uploadWithProgress( file: File, url: string, onProgress: (percent: number) => void ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); onProgress(percent); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed: ${xhr.status}`)); } }); xhr.addEventListener('error', () => reject(new Error('Upload failed'))); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.send(file); }); } ``` ### Drag and Drop Upload ```tsx function DragDropUpload({ onUpload }: { onUpload: (files: File[]) => void }) { const [isDragging, setIsDragging] = useState(false); const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }; const handleDragIn = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragOut = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const files = Array.from(e.dataTransfer.files); onUpload(files); }; return ( {isDragging ? 'Drop files here' : 'Drag files here or click to upload'} ); } ``` ## Available Mutations ### Product Media ```graphql mutation PrepareProductMediaUpload($mediaName: String!, $productId: ID!) { prepareProductMediaUpload(mediaName: $mediaName, productId: $productId) { _id putURL expires } } ``` ```graphql mutation ReorderProductMedia($sortKeys: [ReorderProductMediaInput!]!) { reorderProductMedia(sortKeys: $sortKeys) { _id } } ``` ```graphql mutation RemoveProductMedia($productMediaId: ID!) { removeProductMedia(productMediaId: $productMediaId) { _id } } ``` ### Assortment Media ```graphql mutation PrepareAssortmentMediaUpload($mediaName: String!, $assortmentId: ID!) { prepareAssortmentMediaUpload(mediaName: $mediaName, assortmentId: $assortmentId) { _id putURL expires } } ``` ### User Avatar ```graphql mutation PrepareUserAvatarUpload($mediaName: String!) { prepareUserAvatarUpload(mediaName: $mediaName) { _id putURL expires } } ``` ### Confirm Upload ```graphql mutation ConfirmMediaUpload($mediaUploadTicketId: ID!, $size: Int!, $type: String!) { confirmMediaUpload(mediaUploadTicketId: $mediaUploadTicketId, size: $size, type: $type) { _id name type size url } } ``` ## S3-Compatible Services Unchained works with any S3-compatible storage: ### AWS S3 ```bash MINIO_ENDPOINT=https://s3.amazonaws.com MINIO_ACCESS_KEY=your-aws-access-key MINIO_SECRET_KEY=your-aws-secret-key MINIO_BUCKET_NAME=your-bucket ``` ### DigitalOcean Spaces ```bash MINIO_ENDPOINT=https://nyc3.digitaloceanspaces.com MINIO_ACCESS_KEY=your-spaces-key MINIO_SECRET_KEY=your-spaces-secret MINIO_BUCKET_NAME=your-space-name ``` ### Cloudflare R2 ```bash MINIO_ENDPOINT=https://account-id.r2.cloudflarestorage.com MINIO_ACCESS_KEY=your-r2-access-key MINIO_SECRET_KEY=your-r2-secret-key MINIO_BUCKET_NAME=your-bucket ``` ## Best Practices ### 1. Validate File Types ```typescript const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; function validateFile(file: File): boolean { return ALLOWED_TYPES.includes(file.type); } ``` ### 2. Compress Images Before Upload ```typescript async function compressImage(file: File): Promise { const options = { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true, }; return imageCompression(file, options); } ``` ### 3. Handle Upload Failures ```typescript async function uploadWithRetry( file: File, url: string, maxRetries = 3 ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await uploadFile(file, url); return; } catch (error) { if (attempt === maxRetries) throw error; await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); } } } ``` ## Related - [Files Module](../platform-configuration/modules/files.md) - File module configuration --- ## Developer Guides Practical, step-by-step guides for common development tasks with Unchained Engine. ## Getting Started - [Building a Storefront](./building-a-storefront) - Integrate Unchained with your frontend - [Checkout Implementation](./checkout-implementation) - Complete checkout flow from cart to order ## Payments & Commerce - [Payment Integration](./payment-integration) - Set up and customize payment processing - [Custom Pricing](./custom-pricing) - Implement custom pricing logic with adapters - [Search and Filtering](./search-and-filtering) - Product search and filtering ## Localization - [Multi-Language Setup](./multi-language-setup) - Configure multiple languages - [Multi-Currency Setup](./multi-currency-setup) - Handle multiple currencies and exchange rates ## Data Management - [Bulk Import](./bulk-import) - Import large datasets from PIM/ERP systems - [File Uploads](./file-uploads) - Manage file uploads with MinIO or S3 ## Extensions - [Event Ticketing](./ticketing-setup) - Set up event ticketing with PDF tickets and mobile wallet passes ## Prerequisites Before following these guides, ensure you have: 1. Node.js 22+ installed 2. MongoDB running (or MongoDB Memory Server for development) 3. Basic knowledge of TypeScript and GraphQL 4. An Unchained Engine project set up (see [Quick Start](../quick-start/)) --- ## Multi-Currency Setup This guide covers configuring multiple currencies and handling currency conversion in Unchained Engine. ## Overview Unchained Engine supports multiple currencies with automatic conversion: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Product │────▢│ Pricing System │────▢│ Converted Price β”‚ β”‚ (Base: CHF) β”‚ β”‚ (Exchange Rates) β”‚ β”‚ (Display: EUR) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Configuration ### 1. Set Up Currencies Create currencies in the system: ```graphql mutation CreateCurrency { createCurrency(currency: { isoCode: "EUR" contractAddress: null # For crypto currencies }) { _id isoCode isActive } } ``` Or seed currencies at startup: ```typescript // seed/currencies.ts export const currencies = [ { isoCode: 'CHF', isActive: true }, { isoCode: 'EUR', isActive: true }, { isoCode: 'USD', isActive: true }, { isoCode: 'GBP', isActive: true }, { isoCode: 'ETH', isActive: true, contractAddress: '0x...' }, // Crypto ]; // In your boot script for (const currency of currencies) { await modules.currencies.create(currency); } ``` ### 2. Configure Default Currency Set the default currency via environment variable: ```bash # .env CURRENCY=CHF # Default currency ``` ### 3. Set Country Defaults Link currencies to countries: ```graphql mutation UpdateCountry { updateCountry(countryId: "CH", country: { isoCode: "CH" defaultCurrencyCode: "CHF" }) { _id defaultCurrency { isoCode } } } ``` ## Product Pricing ### Set Base Prices Products store prices in a base currency: ```graphql mutation SetProductPrice { updateProductCommerce(productId: "product-123", commerce: { pricing: [ { currencyCode: "CHF" countryCode: "CH" amount: 4900 # 49.00 CHF in cents isTaxable: true isNetPrice: true } ] }) { _id ... on SimpleProduct { simulatedPrice(currencyCode: "CHF") { amount currencyCode } } } } ``` ### Multi-Currency Prices You can set different prices per currency: ```graphql mutation SetMultiCurrencyPrices { updateProductCommerce(productId: "product-123", commerce: { pricing: [ { currencyCode: "CHF", countryCode: "CH", amount: 4900, isTaxable: true, isNetPrice: true } { currencyCode: "EUR", countryCode: "DE", amount: 4500, isTaxable: true, isNetPrice: true } { currencyCode: "USD", countryCode: "US", amount: 5200, isTaxable: true, isNetPrice: true } ] }) { _id } } ``` ## Exchange Rates Unchained has a generic currency conversion system that allows you to integrate rate feeds from external sources. ### Product Price Rates API To insert or update rates programmatically: ```typescript // Insert/update rates await modules.products.prices.rates.updateRates(productPriceRates); // Get a rate for a currency pair const rate = await modules.products.prices.rates.getRate( baseCurrency, // e.g., 'CHF' quoteCurrency, // e.g., 'EUR' referenceDate // Maximum age of rate ); ``` The `timestamp` field in rate entries determines freshness: - When set to a UNIX timestamp, only rates within the specified maximum age are returned - When set to `null`, the rate is always returned regardless of age The system automatically handles inverse rates - if you have `CHF/EUR`, querying `EUR/CHF` returns the inverse. ### Rate Conversion Plugin The built-in `shop.unchained.pricing.rate-conversion` plugin consumes these rates. Configure the maximum rate age: ```bash # Maximum age in seconds (default: 600 = 10 minutes) CRYPTOPAY_MAX_RATE_AGE=600 ``` ### Manual Exchange Rates Set exchange rates manually via the API: ```typescript // Update exchange rates programmatically await modules.products.prices.rates.updateRates([ { baseCurrency: 'CHF', quoteCurrency: 'EUR', rate: 0.92, timestamp: Date.now(), }, ]); ``` ### Automatic Exchange Rate Updates Use a worker to fetch rates periodically: ```typescript // Configure the worker WorkerDirector.configureAutoscheduling({ type: 'UPDATE_COINBASE_RATES', input: { baseCurrency: 'CHF', }, schedule: '0 0 * * *', // Daily at midnight }); ``` ### Custom Exchange Rate Provider Create a worker to fetch rates from your preferred provider: ```typescript const ExchangeRateWorker: IWorkerAdapter = { key: 'shop.example.worker.exchange-rates', label: 'Custom Exchange Rate Worker', version: '1.0.0', type: 'UPDATE_EXCHANGE_RATES', external: false, maxParallelAllocations: 1, async doWork(input, unchainedAPI) { const { baseCurrency } = input; // Fetch rates from your provider (e.g., Open Exchange Rates, Fixer.io) const response = await fetch( `https://api.exchangerate-api.com/v4/latest/${baseCurrency}` ); const data = await response.json(); // Convert to rate entries with timestamps const rates = Object.entries(data.rates).map(([currency, rate]) => ({ baseCurrency, quoteCurrency: currency, rate: rate as number, timestamp: Date.now(), })); // Update rates in database await unchainedAPI.modules.products.prices.rates.updateRates(rates); return { success: true, result: { updated: rates.length } }; }, }; WorkerDirector.registerAdapter(ExchangeRateWorker); // Schedule hourly updates WorkerDirector.configureAutoscheduling({ type: 'UPDATE_EXCHANGE_RATES', input: { baseCurrency: 'CHF' }, schedule: '0 * * * *', }); ``` ## Currency Conversion Pricing Adapter Create a pricing adapter for automatic conversion: ```typescript ProductPricingAdapter, ProductPricingDirector, } from '@unchainedshop/core-pricing'; class CurrencyConversionAdapter extends ProductPricingAdapter { static key = 'shop.unchained.pricing.currency-conversion'; static orderIndex = 1; // Run early static isActivatedFor({ currencyCode, product }) { // Only if product has no price in requested currency const hasDirectPrice = product.commerce?.pricing?.some( (p) => p.currencyCode === currencyCode ); return !hasDirectPrice; } async calculate() { const { product, currencyCode, modules } = this.context; // Get base price const basePrice = product.commerce?.pricing?.[0]; if (!basePrice) return super.calculate(); // Get exchange rate const rate = await modules.currencies.getExchangeRate( basePrice.currencyCode, currencyCode ); if (rate) { this.result.addItem({ amount: Math.round(basePrice.amount * rate), isTaxable: basePrice.isTaxable, isNetPrice: basePrice.isNetPrice, meta: { adapter: this.constructor.key, convertedFrom: basePrice.currencyCode, rate, }, }); } return super.calculate(); } } ProductPricingDirector.registerAdapter(CurrencyConversionAdapter); ``` ## Querying Prices ### Get Price in Specific Currency ```graphql query ProductPrice($productId: ID!, $currency: String!) { product(productId: $productId) { ... on SimpleProduct { simulatedPrice(currencyCode: $currency, quantity: 1) { amount currencyCode isTaxable isNetPrice } } } } ``` ### Get Prices in Multiple Currencies ```graphql query ProductMultiPrices($productId: ID!) { product(productId: $productId) { ... on SimpleProduct { chfPrice: simulatedPrice(currencyCode: "CHF") { amount currencyCode } eurPrice: simulatedPrice(currencyCode: "EUR") { amount currencyCode } usdPrice: simulatedPrice(currencyCode: "USD") { amount currencyCode } } } } ``` ### Cart in User's Currency ```graphql query CartTotal { me { cart { currency { isoCode } total { amount currencyCode } items { total { amount currencyCode } } } } } ``` ## Frontend Implementation ### Currency Selector ```tsx const CURRENCIES = gql` query Currencies { currencies(includeInactive: false) { _id isoCode } } `; function CurrencySelector() { const { data } = useQuery(CURRENCIES); const [currentCurrency, setCurrentCurrency] = useState('CHF'); const handleChange = (currency: string) => { // Store preference localStorage.setItem('currency', currency); // Update state setCurrentCurrency(currency); // Trigger refetch of prices apolloClient.resetStore(); }; return ( ); } ``` ### Format Currency ```typescript export function formatPrice(amount: number, currency: string): string { const formatter = new Intl.NumberFormat(getLocale(), { style: 'currency', currency, }); // Unchained stores amounts in cents return formatter.format(amount / 100); } // Currency-specific formatting const formatters: Record = { CHF: new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }), EUR: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }), USD: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }), }; export function formatCurrency(amount: number, currency: string): string { const formatter = formatters[currency] || formatters.CHF; return formatter.format(amount / 100); } ``` ### Price Component ```tsx function Price({ amount, currency, className }: { amount: number; currency: string; className?: string; }) { return ( {formatCurrency(amount, currency)} ); } // Usage ``` ## Order Currency Handling ### Set Order Currency When a cart is created, it uses the user's default currency based on their country: ```graphql query CartCurrency { me { cart { currency { isoCode } country { isoCode } } } } ``` The cart currency is automatically determined by the user's country and its default currency setting. To change the effective currency for pricing, you would typically update the user's country or configure the order's context. ## Cryptocurrency Support ### Configure Crypto Currency ```graphql mutation CreateCryptoCurrency { createCurrency(currency: { isoCode: "ETH" contractAddress: "0x0000000000000000000000000000000000000000" decimals: 18 }) { _id isoCode contractAddress isActive } } ``` ### Crypto Pricing ```typescript class CryptoPricingAdapter extends ProductPricingAdapter { static key = 'shop.unchained.pricing.crypto'; static orderIndex: 2; static isActivatedFor({ currencyCode }) { return ['ETH', 'BTC', 'USDC'].includes(currencyCode); } async calculate() { const { currencyCode, modules } = this.context; // Get crypto exchange rate const rate = await fetchCryptoRate(currencyCode); // Convert from base currency const baseTotal = this.calculation.sum({ category: 'BASE' }); this.result.addItem({ amount: convertToCrypto(baseTotal, rate, currencyCode), isTaxable: false, isNetPrice: true, meta: { cryptoRate: rate }, }); return super.calculate(); } } ``` ## Best Practices ### 1. Store Amounts in Smallest Unit Always use the smallest unit (cents, wei, etc.): ```typescript // Good const price = 4999; // 49.99 CHF // Bad const price = 49.99; // Floating point issues ``` ### 2. Handle Rounding Be consistent with rounding: ```typescript // Round to nearest cent const converted = Math.round(basePrice * exchangeRate); ``` ### 3. Cache Exchange Rates Don't fetch rates on every request: ```typescript // Cache rates for 1 hour const rateCache = new Map(); async function getExchangeRate(from: string, to: string): Promise { const key = `${from}-${to}`; const cached = rateCache.get(key); if (cached && cached.expires > Date.now()) { return cached.rate; } const rate = await fetchRate(from, to); rateCache.set(key, { rate, expires: Date.now() + 3600000 }); return rate; } ``` ### 4. Show Original and Converted Prices For transparency, show both prices: ```tsx function ProductPrice({ product, displayCurrency }) { const basePrice = product.commerce?.pricing?.[0]; const displayPrice = product.simulatedPrice; return ( {formatCurrency(displayPrice.amount, displayCurrency)} {basePrice.currencyCode !== displayCurrency && ( (β‰ˆ {formatCurrency(basePrice.amount, basePrice.currencyCode)}) )} ); } ``` ## Related - [Currencies Module](../platform-configuration/modules/currencies) - Currency configuration - [Pricing System](../concepts/pricing-system) - Pricing architecture - [Multi-Language Setup](./multi-language-setup) - Language configuration --- ## Multi-Language Setup This guide covers configuring multiple languages and implementing i18n in your Unchained Engine storefront. ## Overview Unchained Engine stores translations for entities like products, assortments, and filters using a locale-based system: ``` Product └── texts: [ { locale: 'en', title: 'T-Shirt', description: '...' }, { locale: 'de', title: 'T-Shirt', description: '...' }, { locale: 'fr', title: 'T-Shirt', description: '...' } ] ``` ## Configuration ### 1. Set Up Languages Create languages in the system: ```graphql mutation CreateLanguage { createLanguage(language: { isoCode: "de" }) { _id isoCode isActive } } ``` Or seed languages at startup: ```typescript // seed/languages.ts export const languages = [ { isoCode: 'en', isActive: true }, { isoCode: 'de', isActive: true }, { isoCode: 'fr', isActive: true }, { isoCode: 'it', isActive: false }, // Inactive ]; // In your boot script for (const lang of languages) { await modules.languages.create(lang); } ``` ### 2. Configure Default Language Set the default language via environment variable: ```bash # .env LANG=de # Default language ``` ### 3. Set Up Countries Link countries to languages: ```graphql mutation CreateCountry { createCountry(country: { isoCode: "CH" }) { _id isoCode defaultCurrency { isoCode } } } ``` ## Adding Translations ### Product Translations ```graphql mutation UpdateProductTexts { updateProductTexts(productId: "product-123", texts: [ { locale: "en" title: "Organic Cotton T-Shirt" subtitle: "Comfortable everyday wear" description: "Made from 100% organic cotton..." slug: "organic-cotton-t-shirt" } { locale: "de" title: "Bio-Baumwoll T-Shirt" subtitle: "Bequeme Alltagskleidung" description: "Hergestellt aus 100% Bio-Baumwolle..." slug: "bio-baumwoll-t-shirt" } { locale: "fr" title: "T-Shirt en Coton Bio" subtitle: "VΓͺtement de tous les jours confortable" description: "FabriquΓ© Γ  partir de 100% coton bio..." slug: "t-shirt-coton-bio" } ]) { locale title slug } } ``` ### Assortment Translations ```graphql mutation UpdateAssortmentTexts { updateAssortmentTexts(assortmentId: "assortment-123", texts: [ { locale: "en", title: "Men's Clothing", slug: "mens-clothing" } { locale: "de", title: "Herrenbekleidung", slug: "herrenbekleidung" } { locale: "fr", title: "VΓͺtements Homme", slug: "vetements-homme" } ]) { locale title slug } } ``` ### Filter Translations ```graphql mutation UpdateFilterTexts { updateFilterTexts(filterId: "filter-123", filterOptionValue: null, texts: [ { locale: "en", title: "Size" } { locale: "de", title: "Grâße" } { locale: "fr", title: "Taille" } ]) { locale title } } ``` ## Querying Translations ### Automatic Locale Resolution Unchained automatically resolves the `texts` field based on the request locale: ```graphql # Request headers: Accept-Language: de query { product(productId: "...") { texts { title # Returns German title if available description } } } ``` ### Explicit Locale Query all translations: ```graphql query { product(productId: "...") { texts(forceLocale: "en") { title } } } ``` ### All Translations Get all available translations: ```graphql query { product(productId: "...") { # Default (resolved) texts { locale title } # Specific locale germanTexts: texts(forceLocale: "de") { title } } } ``` ## Frontend Implementation ### Language Switcher ```tsx const LANGUAGES = gql` query Languages { languages(includeInactive: false) { _id isoCode name } } `; function LanguageSwitcher() { const { data } = useQuery(LANGUAGES); const [currentLocale, setCurrentLocale] = useState('en'); const handleChange = (locale: string) => { // Update cookie/localStorage document.cookie = `locale=${locale}; path=/`; // Update Apollo client headers apolloClient.setLink( authLink.concat( createHttpLink({ uri: GRAPHQL_URL, headers: { 'Accept-Language': locale, }, }) ) ); // Refetch queries apolloClient.resetStore(); setCurrentLocale(locale); }; return ( ); } ``` ### Next.js i18n Integration ```typescript // next.config.js module.exports = { i18n: { locales: ['en', 'de', 'fr'], defaultLocale: 'en', }, }; ``` ```tsx // pages/products/[slug].tsx export default function ProductPage() { const { locale } = useRouter(); const { data } = useQuery(PRODUCT_QUERY, { context: { headers: { 'Accept-Language': locale, }, }, }); return ( {data?.product?.texts?.title} {data?.product?.texts?.description} ); } ``` ### Apollo Client Setup for i18n ```typescript // lib/apollo-client.ts export function createApolloClient(locale: string) { const httpLink = createHttpLink({ uri: process.env.NEXT_PUBLIC_GRAPHQL_URL, }); const localeLink = setContext((_, { headers }) => ({ headers: { ...headers, 'Accept-Language': locale, }, })); return new ApolloClient({ link: localeLink.concat(httpLink), cache: new InMemoryCache(), }); } ``` ## Server-Side Language Resolution Unchained resolves the locale automatically from the `Accept-Language` HTTP header. The locale is available in the GraphQL context and affects how `texts` fields are resolved. The resolution order is: 1. `Accept-Language` header from the request 2. Default language from the `LANG` environment variable 3. Fallback to `en` ## Bulk Import with Translations ```typescript await modules.bulkImporter.prepare({ entity: 'PRODUCT', data: { _id: 'product-123', type: 'SIMPLE', texts: [ { locale: 'en', title: 'T-Shirt', slug: 't-shirt' }, { locale: 'de', title: 'T-Shirt', slug: 't-shirt-de' }, ], // ... other fields }, }); ``` ## Admin UI Translations The Admin UI supports language management: 1. Go to **Settings > Languages** 2. Add/edit languages 3. Go to any entity (Products, Assortments) 4. Use the locale switcher to edit translations ## Best Practices ### 1. Always Provide Fallback Ensure at least one language (typically English) has complete translations: ```graphql mutation { updateProductTexts(productId: "...", texts: [ { locale: "en", title: "Fallback Title" } # Always provide { locale: "de", title: "German Title" } # Optional ]) { locale title } } ``` ### 2. Use Slugs Per Locale Different slugs allow for SEO-friendly URLs: ``` /en/products/organic-t-shirt /de/products/bio-t-shirt /fr/products/t-shirt-bio ``` ### 3. Handle Missing Translations ```tsx function ProductTitle({ product }) { const title = product.texts?.title; if (!title) { // Fallback to product ID or show placeholder return {product._id}; } return {title}; } ``` ### 4. Validate Translations Check for missing translations: ```graphql query ProductsWithMissingTranslations { products { _id texts { locale title } } } ``` ```typescript // Find products missing German translations const missingDE = products.filter( (p) => !p.texts.some((t) => t.locale === 'de') ); ``` ## Related - [Languages Module](../platform-configuration/modules/languages) - Language configuration - [Multi-Currency Setup](./multi-currency-setup) - Currency configuration - [Bulk Import](./bulk-import) - Importing translations --- ## Payment Integration This guide covers setting up payment processing in Unchained Engine, from configuring built-in providers to creating custom integrations. ## Overview Unchained Engine supports multiple payment providers through the plugin system: ```mermaid flowchart LR S[Storefront] <--> U[Unchained EnginePaymentDirector] <--> P[Payment GatewayStripe, etc.] ``` ## Built-in Payment Providers | Provider | Type | Use Case | |----------|------|----------| | [Stripe](../plugins/payment/stripe.md) | CARD | Credit/debit cards | | [Braintree](../plugins/payment/braintree.md) | CARD | Cards, PayPal | | [Datatrans](../plugins/payment/datatrans.md) | CARD | Swiss payment gateway | | [Saferpay](../plugins/payment/saferpay.md) | CARD | Swiss payment gateway | | [Cryptopay](../plugins/payment/cryptopay.md) | GENERIC | Cryptocurrency | | [Invoice](../plugins/payment/invoice.md) | INVOICE | Manual invoicing | ## Quick Start: Stripe ### 1. Install and Configure ```bash npm install stripe ``` ```typescript // boot.ts ``` ```bash # .env STRIPE_SECRET_KEY=sk_test_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx ``` ### 2. Create Payment Provider Create a payment provider in the Admin UI or via GraphQL: ```graphql mutation CreateStripeProvider { createPaymentProvider(paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.stripe" }) { _id type interface { _id label } } } ``` ### 3. Frontend Integration ```tsx const stripePromise = loadStripe('pk_test_xxx'); function CheckoutForm() { const stripe = useStripe(); const elements = useElements(); const [signPayment] = useMutation(SIGN_PAYMENT); const [checkout] = useMutation(CHECKOUT); const handleSubmit = async (e) => { e.preventDefault(); // Get client secret from Unchained const { data } = await signPayment({ variables: { orderPaymentId: cart.payment._id }, }); // Confirm payment with Stripe const { error, paymentIntent } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${window.location.origin}/checkout/complete`, }, redirect: 'if_required', }); if (error) { setError(error.message); } else if (paymentIntent.status === 'succeeded') { // Complete checkout await checkout({ variables: { paymentContext: { paymentIntentId: paymentIntent.id }, }, }); } }; return (
); } function PaymentPage() { const { data } = useQuery(GET_CART); const clientSecret = data?.me?.cart?.payment?.clientSecret; if (!clientSecret) return Loading...; return ( ); } ``` ### 4. Webhook Handler ```typescript // api/webhooks/stripe.ts const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); export const config = { api: { bodyParser: false } }; export default async function handler(req, res) { const sig = req.headers['stripe-signature']; const buf = await buffer(req); let event; try { event = stripe.webhooks.constructEvent( buf, sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`); } switch (event.type) { case 'payment_intent.succeeded': // Payment successful - order will auto-confirm console.log('Payment succeeded:', event.data.object.id); break; case 'payment_intent.payment_failed': // Payment failed console.log('Payment failed:', event.data.object.id); break; } res.json({ received: true }); } ``` ## Payment Flow ### Standard Flow ```mermaid flowchart TD A[1. User selects payment provider] --> B[2. Initialize payment - get client token] B --> C[3. User completes payment on frontend] C --> D[4. Checkout order] D --> E[5. Webhook confirms payment] E --> F[Order CONFIRMED] ``` ### GraphQL Mutations ```graphql # Step 1: Set payment provider mutation SetPaymentProvider($orderId: ID!, $paymentProviderId: ID!) { setOrderPaymentProvider(orderId: $orderId, paymentProviderId: $paymentProviderId) { _id payment { _id provider { _id interface { label } } } } } # Step 2: Sign payment (get client token) mutation SignPayment($orderPaymentId: ID!) { signPaymentProviderForCheckout(orderPaymentId: $orderPaymentId) } # Step 4: Checkout mutation Checkout($orderId: ID, $paymentContext: JSON) { checkoutCart(orderId: $orderId, paymentContext: $paymentContext) { _id status orderNumber payment { status } } } ``` ## Payment Provider Configuration ### Configure via Admin UI 1. Go to **Settings > Payment Providers** 2. Click **Create Provider** 3. Select adapter (e.g., Stripe) 4. Set configuration values 5. Save and activate ### Configure via GraphQL ```graphql mutation ConfigureStripe { createPaymentProvider(paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.stripe" }) { _id } } ``` Configure `merchantCountry` via the Admin UI after creation. ### Environment Variables Most payment adapters use environment variables: ```bash # Stripe STRIPE_SECRET_KEY=sk_xxx STRIPE_PUBLISHABLE_KEY=pk_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx # PayPal PAYPAL_CLIENT_ID=xxx PAYPAL_CLIENT_SECRET=xxx PAYPAL_ENVIRONMENT=sandbox # or production # Datatrans DATATRANS_MERCHANT_ID=xxx DATATRANS_PASSWORD=xxx DATATRANS_SIGN_KEY=xxx ``` ## Custom Payment Adapter Create a custom adapter for payment gateways not covered by built-in plugins: ```typescript const MyPaymentAdapter: IPaymentAdapter = { key: 'com.mycompany.payment.custom', label: 'My Payment Gateway', version: '1.0.0', typeSupported(type) { return type === 'CARD'; }, actions(params) { const { paymentContext, context } = params; const { order, orderPayment } = paymentContext; return { configurationError() { if (!process.env.MY_GATEWAY_API_KEY) { return { code: 'MISSING_API_KEY' }; } return null; }, isActive() { return true; }, isPayLaterAllowed() { return false; // Require payment before order confirmation }, async sign() { // Create payment session with gateway const session = await myGateway.createSession({ amount: order.pricing().total().amount, currency: order.currency, orderId: order._id, }); return session.clientToken; }, async charge() { // Check if payment is complete const { transactionId } = orderPayment.context || {}; if (transactionId) { const payment = await myGateway.getPayment(transactionId); if (payment.status === 'completed') { return { transactionId }; } } return false; }, async cancel() { const { transactionId } = orderPayment; if (transactionId) { await myGateway.refund(transactionId); } return true; }, async confirm() { return { transactionId: orderPayment.transactionId }; }, async register() { return { token: '' }; }, async validate() { return true; }, }; }, }; PaymentDirector.registerAdapter(MyPaymentAdapter); ``` ## Testing Payments ### Test Mode Most payment providers have test/sandbox modes: ```bash # Stripe test keys STRIPE_SECRET_KEY=sk_test_xxx STRIPE_PUBLISHABLE_KEY=pk_test_xxx # PayPal sandbox PAYPAL_ENVIRONMENT=sandbox ``` ### Test Card Numbers | Provider | Card Number | Description | |----------|-------------|-------------| | Stripe | 4242 4242 4242 4242 | Successful payment | | Stripe | 4000 0000 0000 0002 | Declined | | Stripe | 4000 0025 0000 3155 | Requires 3DS | | PayPal | N/A | Use sandbox accounts | ### Testing Webhooks Locally Use Stripe CLI or ngrok for local webhook testing: ```bash # Stripe CLI stripe listen --forward-to localhost:3000/api/webhooks/stripe # ngrok ngrok http 3000 # Configure webhook URL in Stripe dashboard ``` ## Error Handling ### Common Payment Errors | Error | Cause | User Message | |-------|-------|--------------| | `card_declined` | Card was declined | "Your card was declined" | | `insufficient_funds` | Not enough funds | "Insufficient funds" | | `expired_card` | Card expired | "Card has expired" | | `incorrect_cvc` | Wrong CVC | "Invalid security code" | | `processing_error` | Gateway error | "Please try again" | ### Error Handling in Frontend ```typescript try { const { error } = await stripe.confirmPayment({ /* ... */ }); if (error) { switch (error.code) { case 'card_declined': setError('Your card was declined. Please try another card.'); break; case 'expired_card': setError('Your card has expired. Please use a different card.'); break; default: setError('Payment failed. Please try again.'); } } } catch (err) { setError('An unexpected error occurred.'); } ``` ## Payment Fees Add payment processing fees to orders: ```typescript class CardFeeAdapter extends PaymentPricingAdapter { static key = 'shop.unchained.pricing.card-fee'; static orderIndex = 0; static isActivatedFor({ provider }) { return provider.type === 'CARD'; } async calculate() { const { order } = this.context; const total = order.pricing().total().amount; // 2.9% + 30 cents const fee = Math.round(total * 0.029 + 30); this.result.addItem({ amount: fee, isTaxable: false, isNetPrice: true, category: 'PAYMENT', }); return super.calculate(); } } PaymentPricingDirector.registerAdapter(CardFeeAdapter); ``` ## Multi-Currency Support Handle multiple currencies: ```typescript async sign() { const { order } = this.paymentContext; const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [{ price_data: { currency: order.currency.toLowerCase(), product_data: { name: `Order ${order.orderNumber}` }, unit_amount: order.pricing().total().amount, }, quantity: 1, }], mode: 'payment', success_url: `${process.env.ROOT_URL}/checkout/success`, cancel_url: `${process.env.ROOT_URL}/checkout/cancel`, }); return session.id; } ``` ## Related - [Stripe Plugin](../plugins/payment/stripe.md) - Stripe payment adapter - [Director/Adapter Pattern](../concepts/director-adapter-pattern.md) - Plugin architecture - [Checkout Implementation](./checkout-implementation.md) - Full checkout flow --- ## Search and Filtering This guide covers implementing product search and filtering in your storefront. ## Overview Unchained Engine provides a flexible search and filter system: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Storefront │────▢│ FilterDirector │────▢│ Filter Adapters β”‚ β”‚ (Search) │◀────│ (Aggregation) │◀────│ (Search Logic) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Basic Search ### Text Search Query ```graphql query SearchProducts($query: String!) { searchProducts(queryString: $query) { filteredProductsCount products { _id texts { title description } ... on SimpleProduct { simulatedPrice(currencyCode: "CHF") { amount currencyCode } } media { file { url } } } } } ``` ### Search with Pagination ```graphql query SearchWithPagination($query: String, $limit: Int, $offset: Int) { searchProducts( queryString: $query, ) { filteredProductsCount products(limit: $limit, offset: $offset) { _id texts { title } } } } ``` ## Filters ### Get Available Filters ```graphql query GetFilters { filters { texts { title } options { texts { title } } } } ``` ### Search with Filters ```graphql query FilteredSearch($query: String, $filters: [FilterQueryInput!]) { searchProducts( queryString: $query filterQuery: $filters ) { filteredProductsCount products { _id texts { title } } filters { filteredProductsCount isSelected options { filteredProductsCount isSelected } } } } ``` ### Filter Query Input ```typescript // Example filter queries const filters = [ // Single value { key: 'category', value: 'electronics' }, // Multiple values (OR) { key: 'brand', value: 'apple' }, { key: 'brand', value: 'samsung' }, // Range filter { key: 'price', value: '100-500' }, ]; ``` ## Filter Types | Type | Description | Example | |------|-------------|---------| | `SINGLE_CHOICE` | Select one option | Category | | `MULTI_CHOICE` | Select multiple options | Brand, Color | | `RANGE` | Numeric range | Price, Weight | | `SWITCH` | Boolean toggle | In Stock | ### Creating Filters ```graphql mutation CreateFilter { createFilter( filter: { key: "brand" type: MULTI_CHOICE options: ["apple", "samsung"] } texts: [ { locale: "en", title: "Brand" } ] ) { _id key type texts { title } } } ``` Note: Filter option texts are managed separately via `updateFilterTexts`. ### Assigning Filters to Products Filters are typically assigned through the Assortment system. Products inherit filters from their assortments, and filter options are managed separately through filter configuration. ```graphql mutation LinkAssortmentFilter { addAssortmentFilter(assortmentId: "product-assortment", filterId: "filter-id") { _id assortment { _id texts { _id title } } filter { _id texts { _id title } } } } ``` ```graphql mutation LinkAssortmentProduct { addAssortmentProduct(assortmentId: "product-assortment", productId: "product-id") { _id assortment { _id texts { _id title } } product { _id texts { _id title } } } } ``` ```graphql query ProductFilters { product(productId: "product-id") { _id texts { title } assortmentPaths { links { assortmentId } } } } ``` ## Assortment-Based Filtering Filter products within an assortment (category): ```graphql query AssortmentProducts($assortmentId: ID!, $filters: [FilterQueryInput!]) { assortment(assortmentId: $assortmentId) { _id texts { title } searchProducts(filterQuery: $filters) { filteredProductsCount products { _id texts { title } } filters { filteredProductsCount isSelected options { filteredProductsCount } } } } } ``` ## Frontend Implementation ### Search Component ```tsx function SearchBar() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); const [search, { data, loading }] = useLazyQuery(SEARCH_PRODUCTS); useEffect(() => { if (debouncedQuery.length >= 2) { search({ variables: { query: debouncedQuery } }); } }, [debouncedQuery, search]); return ( setQuery(e.target.value)} placeholder="Search products..." /> {loading && Searching...} {data?.searchProducts?.products && ( {data.searchProducts.products.map((product) => ( ))} Found {data.searchProducts.filteredProductsCount} products )} ); } ``` ### Filter Sidebar ```tsx function FilterSidebar({ filters, selectedFilters, onFilterChange }) { return ( {filters.map((filter) => ( f.key === filter.definition._id)} onChange={(values) => onFilterChange(filter.definition._id, values)} /> ))} ); } function FilterGroup({ filter, selected, onChange }) { const selectedValues = selected.map((s) => s.value); const handleToggle = (value) => { if (selectedValues.includes(value)) { onChange(selectedValues.filter((v) => v !== value)); } else { onChange([...selectedValues, value]); } }; return ( {filter.texts?.title} {filter.options.map((option) => ( ))} ); } ``` ### Product List with Filters ```tsx function ProductListPage({ initialQuery = '' }) { const [query, setQuery] = useState(initialQuery); const [selectedFilters, setSelectedFilters] = useState([]); const [sortBy, setSortBy] = useState('relevance'); const { data, loading } = useQuery(SEARCH_PRODUCTS, { variables: { query, filters: selectedFilters, sortBy, }, }); const handleFilterChange = (key, values) => { // Remove old filters for this key const otherFilters = selectedFilters.filter((f) => f.key !== key); // Add new filters const newFilters = values.map((value) => ({ key, value })); setSelectedFilters([...otherFilters, ...newFilters]); }; const clearFilters = () => { setSelectedFilters([]); }; return ( setQuery(e.target.value)} placeholder="Search..." /> {selectedFilters.length > 0 && ( {selectedFilters.map((filter, i) => ( {filter.key}: {filter.value} ))} )} {loading ? ( Loading... ) : ( <> {data?.searchProducts?.filteredProductsCount} products found {data?.searchProducts?.products.map((product) => ( ))} )} ); } ``` ## URL-Based Filters Sync filters with URL for shareable links: ```tsx function useUrlFilters() { const router = useRouter(); // Parse filters from URL const filters = useMemo(() => { const result = []; Object.entries(router.query).forEach(([key, value]) => { if (key.startsWith('filter_')) { const filterKey = key.replace('filter_', ''); const values = Array.isArray(value) ? value : [value]; values.forEach((v) => result.push({ key: filterKey, value: v })); } }); return result; }, [router.query]); // Update URL when filters change const setFilters = (newFilters) => { const query = { ...router.query }; // Remove old filter params Object.keys(query).forEach((key) => { if (key.startsWith('filter_')) delete query[key]; }); // Add new filter params newFilters.forEach(({ key, value }) => { const paramKey = `filter_${key}`; if (query[paramKey]) { query[paramKey] = Array.isArray(query[paramKey]) ? [...query[paramKey], value] : [query[paramKey], value]; } else { query[paramKey] = value; } }); router.push({ query }, undefined, { shallow: true }); }; return { filters, setFilters }; } ``` ## Custom Filter Adapter Create a custom filter adapter for advanced search: ```typescript const ElasticsearchFilter: IFilterAdapter = { key: 'shop.example.filter.elasticsearch', label: 'Elasticsearch Filter', version: '1.0.0', orderIndex: 0, actions(context) { return { async searchProducts(params, options) { const { queryString, filterQuery } = params; // Build Elasticsearch query const esQuery = buildESQuery(queryString, filterQuery); // Search Elasticsearch const results = await elasticsearch.search({ index: 'products', body: esQuery, }); return { productIds: results.hits.hits.map((hit) => hit._id), totalCount: results.hits.total.value, }; }, async searchAssortments(params, options) { // Similar implementation for assortments return { assortmentIds: [], totalCount: 0 }; }, async aggregateProductIds(params) { // Return product IDs matching filter return []; }, transformProductSelector(selector, options) { return selector; }, transformFilterSelector(selector, options) { return selector; }, transformSortStage(sort, options) { return sort; }, }; }, }; FilterDirector.registerAdapter(ElasticsearchFilter); ``` ## Performance Tips ### 1. Index Your Filters Ensure MongoDB indexes exist for filter fields: ```typescript // In your migration or setup await db.collection('products').createIndex({ 'filterOptions.key': 1 }); await db.collection('products').createIndex({ 'filterOptions.value': 1 }); await db.collection('products').createIndex({ status: 1, 'texts.title': 'text' }); ``` ### 2. Cache Filter Aggregations Use Apollo cache or server-side caching: ```typescript const { data } = useQuery(SEARCH_PRODUCTS, { variables: { query, filters }, fetchPolicy: 'cache-and-network', }); ``` ### 3. Debounce Search Input ```typescript function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(handler); }, [value, delay]); return debouncedValue; } ``` ## Related - [Filter Plugins](../plugins/) - Filter adapters - [Custom Filter Adapter](../extend/catalog/filter) - Building custom adapters - [Search Behavior](../extend/catalog/search-behavior) - Search customization --- ## Seed Data When starting a fresh Unchained Engine instance, you need to seed it with initial data such as an admin user, languages, currencies, and countries. ## Seed Function Pattern Create a seed function that receives the `UnchainedCore` API and provisions essential data: ```typescript export default async function seed(unchainedAPI: UnchainedCore) { const { modules } = unchainedAPI; // Skip if already seeded const adminCount = await modules.users.count({ username: 'admin' }); if (adminCount > 0) return; // Seed data here... } ``` Call the seed function after platform startup: ```typescript const platform = await startPlatform({ modules: defaultModules }); await seed(platform.unchainedAPI); ``` ## Essential Seed Data ### 1. Admin User ```typescript const adminPassword = process.env.UNCHAINED_SEED_PASSWORD || crypto.randomUUID(); await modules.users.createUser( { email: 'admin@unchained.local', guest: false, initialPassword: true, password: modules.users.hashPassword(adminPassword), profile: { address: {} }, roles: ['admin'], username: 'admin', }, { skipMessaging: true }, ); console.log(`Admin password: ${adminPassword}`); ``` ### 2. Languages ```typescript const language = process.env.UNCHAINED_LANG || 'de'; await modules.languages.create({ isoCode: language, isActive: true, }); ``` ### 3. Currencies ```typescript const currency = process.env.UNCHAINED_CURRENCY || 'CHF'; await modules.currencies.create({ isoCode: currency, isActive: true, }); ``` ### 4. Countries ```typescript const country = process.env.UNCHAINED_COUNTRY || 'CH'; await modules.countries.create({ isoCode: country, isActive: true, defaultCurrencyCode: currency, }); ``` ### 5. Payment Provider ```typescript await modules.payment.paymentProviders.create({ adapterKey: 'shop.unchained.invoice', type: 'INVOICE', configuration: [], }); ``` ### 6. Delivery Provider ```typescript await modules.delivery.create({ adapterKey: 'shop.unchained.delivery.send-message', type: 'SHIPPING', configuration: [ { key: 'from', value: process.env.EMAIL_FROM || 'noreply@localhost' }, { key: 'to', value: process.env.UNCHAINED_MAIL_RECIPIENT || 'orders@localhost' }, ], }); ``` ## Complete Seed Example ```typescript export default async function seed(unchainedAPI: UnchainedCore) { const { modules } = unchainedAPI; const adminCount = await modules.users.count({ username: 'admin' }); if (adminCount > 0) return; const password = process.env.UNCHAINED_SEED_PASSWORD || crypto.randomUUID(); const lang = process.env.UNCHAINED_LANG || 'en'; const currency = process.env.UNCHAINED_CURRENCY || 'CHF'; const country = process.env.UNCHAINED_COUNTRY || 'CH'; // Admin user await modules.users.createUser( { email: 'admin@unchained.local', guest: false, initialPassword: true, password: modules.users.hashPassword(password), profile: { address: {} }, roles: ['admin'], username: 'admin', }, { skipMessaging: true }, ); // Localization await modules.languages.create({ isoCode: lang, isActive: true }); await modules.currencies.create({ isoCode: currency, isActive: true }); await modules.countries.create({ isoCode: country, isActive: true, defaultCurrencyCode: currency, }); // Payment & Delivery await modules.payment.paymentProviders.create({ adapterKey: 'shop.unchained.invoice', type: 'INVOICE', configuration: [], }); await modules.delivery.create({ adapterKey: 'shop.unchained.delivery.send-message', type: 'SHIPPING', configuration: [ { key: 'from', value: process.env.EMAIL_FROM || 'noreply@localhost' }, { key: 'to', value: process.env.UNCHAINED_MAIL_RECIPIENT || 'orders@localhost' }, ], }); console.log(`Seeded admin user with password: ${password}`); } ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `UNCHAINED_SEED_PASSWORD` | Random UUID | Admin user password | | `UNCHAINED_LANG` | `de` | Default language ISO code | | `UNCHAINED_CURRENCY` | `CHF` | Default currency ISO code | | `UNCHAINED_COUNTRY` | `CH` | Default country ISO code | | `EMAIL_FROM` | `noreply@localhost` | Delivery notification sender | | `UNCHAINED_MAIL_RECIPIENT` | `orders@localhost` | Delivery notification recipient | ## Idempotency Always check if data exists before seeding. The seed function should be safe to run multiple times: ```typescript const adminCount = await modules.users.count({ username: 'admin' }); if (adminCount > 0) return; ``` ## Related - [Quick Start](../quick-start/index.md) - Getting started - [Environment Variables](../platform-configuration/environment-variables.md) - All configuration - [Multi-Language Setup](./multi-language-setup.md) - Multi-language support - [Multi-Currency Setup](./multi-currency-setup.md) - Multi-currency support --- ## Express vs Fastify Setup # Server Setup Unchained Engine supports both Express and Fastify as HTTP server frameworks. Both provide identical functionality through the `connect()` function from `@unchainedshop/api`. ## Quick Comparison | Aspect | Express | Fastify | |--------|---------|---------| | Performance | Good | Better (2-3x faster) | | Async Support | Callback-based | Native async/await | | Plugin System | Middleware | Hooks and decorators | | TypeScript | Good | Excellent | | Ecosystem | Largest | Growing | | Recommended | Teams familiar with Express | New projects | ## Fastify Setup (Recommended) Fastify is the recommended choice for new projects. It's used by the default kitchensink example. ```typescript registerAllPlugins(); const fastify = Fastify({ loggerInstance: unchainedLogger('fastify'), disableRequestLogging: true, trustProxy: true, }); const platform = await startPlatform({ modules: defaultModules, }); connect(fastify, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== 'production', adminUI: true, }); await fastify.listen({ host: '::', port: process.env.PORT ? parseInt(process.env.PORT) : 3000, }); ``` ## Express Setup ```typescript registerAllPlugins(); const app = express(); const httpServer = http.createServer(app); const platform = await startPlatform({ modules: defaultModules, }); connect(app, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== 'production', }); await httpServer.listen({ port: process.env.PORT || 3000 }); ``` ## Connect Options Both adapters accept the same options in the third argument: ```typescript connect(server, platform, { // Allow secure cookies over HTTP in development (blocked in production) allowRemoteToLocalhostSecureCookies: boolean, // Enable the Admin UI at the root path or configure with prefix adminUI: boolean | { prefix: string }, // AI chat configuration (requires OpenAI-compatible provider) chat: { model: ChatModel, imageGeneration?: ImageModel }, // OIDC/OAuth authentication configuration authConfig: AuthConfig, // Trust X-Forwarded-* headers from reverse proxy trustProxy: boolean, }); ``` ## Key Differences ### HTTP Server Creation Express requires manually creating an HTTP server, while Fastify handles it internally: ```typescript // Express - manual HTTP server const app = express(); const httpServer = http.createServer(app); await httpServer.listen({ port: 3000 }); // Fastify - built-in server const fastify = Fastify({ trustProxy: true }); await fastify.listen({ host: '::', port: 3000 }); ``` ### Logger Integration ```typescript // Express - use createLogger from @unchainedshop/logger const logger = createLogger('app'); // Fastify - use unchainedLogger wrapper const fastify = Fastify({ loggerInstance: unchainedLogger('fastify'), }); ``` ### Adding Custom Middleware ```typescript // Express - standard middleware app.use('/api/custom', (req, res) => { res.json({ hello: 'world' }); }); // Fastify - route registration fastify.route({ method: 'GET', url: '/api/custom', handler: async (request, reply) => { return { hello: 'world' }; }, }); ``` ## Admin UI Both adapters support serving the Admin UI. Enable it via the `adminUI` option: ```typescript // Enable at root path connect(server, platform, { adminUI: true }); // Enable with custom prefix connect(server, platform, { adminUI: { prefix: '/admin' } }); ``` ## OIDC Authentication For enterprise authentication with external identity providers, pass the `authConfig` option. See the [OIDC example](https://github.com/unchainedshop/unchained/tree/master/examples/oidc) for a complete implementation. ## Related - [Quick Start](../quick-start/index.md) - Get started with Unchained Engine - [Environment Variables](../platform-configuration/environment-variables.md) - Configuration options - [Deployment](../deployment/docker.md) - Deploy to production --- ## Testing Unchained Engine uses Node.js built-in test runner for both unit and integration tests. This guide covers how to test custom plugins, modules, and integrations. ## Running Tests ### All Tests ```bash npm run test ``` ### Unit Tests Only ```bash npm run test:run:unit ``` ### Integration Tests ```bash npm run test:run:integration ``` ### Single Test File ```bash # Unit test node --test path/to/test.ts # Integration test (from monorepo root) node --no-warnings \ --env-file .env.tests \ --env-file-if-exists=.env \ --test-isolation=none \ --test-force-exit \ --test-global-setup=tests/helpers.js \ --test \ --test-concurrency=1 \ path/to/test.ts ``` ## Unit Testing Unit tests validate individual functions and adapters in isolation. ### Testing a Custom Plugin ```typescript describe('My Custom Payment Adapter', () => { const adapter = MyPaymentAdapter; it('should have correct key', () => { assert.equal(adapter.key, 'shop.example.payment.custom'); }); it('should have correct type', () => { assert.equal(adapter.type, 'GENERIC'); }); it('should support the adapter interface', () => { assert.ok(adapter.initialConfiguration); assert.ok(adapter.actions); }); it('should validate configuration', () => { const actions = adapter.actions({ config: [{ key: 'apiKey', value: 'test-key' }], }); assert.ok(actions.isActive()); }); }); ``` ### Testing a Pricing Plugin ```typescript describe('Custom Discount Adapter', () => { it('should calculate 10% discount', () => { const adapter = new MyDiscountAdapter({ context: { order: { _id: 'test-order' }, orderDiscount: { code: 'SAVE10' }, }, }); const discount = adapter.discountForPricingAdapterKey({ pricingAdapterKey: 'shop.unchained.pricing.product-catalog-price', }); assert.deepEqual(discount, { rate: 0.1 }); }); }); ``` ## Integration Testing Integration tests run against a live Unchained instance with MongoDB. ### Test Setup Integration tests use the kitchensink example as the test harness. The global setup in `tests/helpers.js` bootstraps the platform. ### Environment Create a `.env.tests` file: ```bash MONGO_URL=mongodb://localhost:27017/unchained-tests UNCHAINED_TOKEN_SECRET=test-secret-minimum-32-characters-long ``` ### Writing an Integration Test ```typescript describe('Order Checkout Flow', () => { let graphqlFetch; // Use the admin client from test helpers it('should create and checkout an order', async () => { // Create a product const { data: { createProduct } } = await graphqlFetch({ query: ` mutation { createProduct( product: { type: SIMPLE_PRODUCT, title: "Test Product" } ) { _id } } `, }); // Add to cart const { data: { addCartProduct } } = await graphqlFetch({ query: ` mutation AddToCart($productId: ID!) { addCartProduct(productId: $productId, quantity: 1) { _id } } `, variables: { productId: createProduct._id }, }); assert.ok(addCartProduct._id); }); }); ``` ### GraphQL Test Client The test helpers provide authenticated GraphQL clients: ```typescript const adminGraphqlFetch = await createLoggedInGraphqlFetch({ email: 'admin@unchained.local', password: 'password', }); // Use for admin operations const result = await adminGraphqlFetch({ query: '{ users { _id } }', }); ``` ## Testing Custom Modules ```typescript describe('Custom Module', () => { let unchainedAPI; it('should initialize', async () => { const platform = await startPlatform({ modules: { customModule: { configure: async ({ db }) => ({ findItems: async () => [], createItem: async (data) => ({ _id: 'new', ...data }), }), }, }, }); unchainedAPI = platform.unchainedAPI; assert.ok(unchainedAPI.modules.customModule); }); it('should create items', async () => { const item = await unchainedAPI.modules.customModule.createItem({ name: 'Test', }); assert.equal(item.name, 'Test'); }); }); ``` ## Best Practices 1. **Use `.env.tests`** for test-specific configuration to avoid affecting development data 2. **Run with `--test-concurrency=1`** for integration tests to avoid race conditions 3. **Clean up test data** after each test suite to keep tests independent 4. **Test adapters in isolation** before integration testing with the full platform 5. **Use `--test-isolation=none`** for integration tests that share platform state ## Related - [Custom Modules](../extend/custom-modules.md) - Build custom modules - [Director/Adapter Pattern](../concepts/director-adapter-pattern.md) - Plugin architecture - [Worker](../extend/worker.md) - Custom workers --- ## Event Ticketing Setup This guide covers setting up the `@unchainedshop/ticketing` extension for event ticketing functionality, including PDF ticket generation and mobile wallet passes. ## Overview The Unchained Ticketing extension provides: - **PDF Tickets**: Generate downloadable PDF tickets for orders - **Apple Wallet**: Create `.pkpass` files for Apple Wallet - **Google Wallet**: Generate Google Wallet pass links - **Magic Key Access**: Allow users to access tickets without logging in ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Order │────▢│ Ticketing API │────▢│ Ticket Renderers β”‚ β”‚ (Tokens) β”‚ β”‚ (Magic Keys) β”‚ β”‚ (PDF, Wallet) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` By default the module provides a SVG bare bone ticket, google wallet and apple pass templates. In order to use google wallet and apple pass templates you need to install the required dependencies `googleapis` & `jsonwebtoken` for google wallet `@walletpass/pass-js` for apple pass beforehand. ## Installation ```bash npm install @unchainedshop/ticketing npm install googleapis jsonwebtoken @walletpass/pass-js ``` ## Basic Setup ### 1. Configure Platform with Ticketing ```typescript const fastify = Fastify({ loggerInstance: unchainedLogger('fastify'), disableRequestLogging: true, trustProxy: true, }); const platform = await startPlatform({ modules: { ...baseModules, ...ticketingModules }, services: { ...ticketingServices }, }); // Setup ticketing with your custom renderers setupTicketing(platform.unchainedAPI as TicketingAPI, { renderOrderPDF: undefined, // use default SVG template createAppleWalletPass: configureAppleWalletPass({ templateConfig: { description: 'Event Ticket', organizationName: 'Unchained Commerce', passTypeIdentifier: process.env.PASS_TYPE_IDENTIFIER || 'pass.com.example.ticket', teamIdentifier: process.env.PASS_TEAM_ID, backgroundColor: 'rgb(255,255,255)', foregroundColor: 'rgb(50,50,50)', }, // Optional: customize field labels for localization labels: { eventLabel: 'Event', locationLabel: 'Venue', ticketNumberLabel: 'Ticket #', infoLabel: 'Details', slotChangeMessage: 'Event time changed: %@', barcodeHint: 'Scan for entry', }, }), createGoogleWalletPass: configureGoogleWalletPass({ issuerName: 'Unchained Commerce', countryCode: 'CH', hexBackgroundColor: '#FFFFFF', homepageUri: { uri: 'https://unchained.shop', description: 'Event Website', }, }), }); // Connect Unchained to Fastify connect(fastify, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== 'production', initPluginMiddlewares: (app) => { connectBasePluginsToFastify(app); connectTicketingToFastify(app); } }); await fastify.listen({ host: '::', port: 3000 }); ``` ### 2. Express Alternative ```typescript const app = express(); const platform = await startPlatform({ modules: { ...baseModules, ...ticketingModules }, services: { ...ticketingServices }, }); setupTicketing(platform.unchainedAPI, { renderOrderPDF: undefined, // use default SVG template createAppleWalletPass: configureAppleWalletPass({ templateConfig: { description: 'Event Ticket', organizationName: 'Unchained Commerce', passTypeIdentifier: process.env.PASS_TYPE_IDENTIFIER || 'pass.com.example.ticket', teamIdentifier: process.env.PASS_TEAM_ID, backgroundColor: 'rgb(255,255,255)', foregroundColor: 'rgb(50,50,50)', }, // Optional: customize field labels for localization labels: { eventLabel: 'Event', locationLabel: 'Venue', ticketNumberLabel: 'Ticket #', infoLabel: 'Details', slotChangeMessage: 'Event time changed: %@', barcodeHint: 'Scan for entry', }, }), createGoogleWalletPass: configureGoogleWalletPass({ issuerName: 'Unchained Commerce', countryCode: 'CH', hexBackgroundColor: '#FFFFFF', homepageUri: { uri: 'https://unchained.shop', description: 'Event Website', }, }), }); connectTicketingToExpress(app); ``` ## PDF Ticket Rendering Create a PDF renderer using `@react-pdf/renderer`: ```bash npm install @react-pdf/renderer ``` ```tsx const styles = StyleSheet.create({ page: { padding: 30, fontFamily: 'Helvetica', }, header: { fontSize: 24, marginBottom: 20, textAlign: 'center', }, ticketContainer: { border: '1px solid #ccc', padding: 20, marginBottom: 20, }, qrCode: { width: 150, height: 150, alignSelf: 'center', }, details: { marginTop: 20, }, label: { fontSize: 10, color: '#666', }, value: { fontSize: 14, marginBottom: 10, }, }); interface TicketData { tokenId: string; eventName: string; eventDate: string; venue: string; seat?: string; qrCodeUrl: string; } const TicketDocument = ({ tickets, orderNumber }: { tickets: TicketData[]; orderNumber: string }) => ( Your Tickets Order: {orderNumber} {tickets.map((ticket, index) => ( {ticket.eventName} Date {ticket.eventDate} Venue {ticket.venue} {ticket.seat && ( <> Seat {ticket.seat} )} Ticket ID {ticket.tokenId} ))} ); // Export the renderer function export default async function renderOrderPDF( { orderId, variant }: { orderId: string; variant?: string }, unchainedAPI: TicketingAPI ) { const { modules } = unchainedAPI; const order = await modules.orders.findOrder({ orderId }); const tokens = await modules.warehousing.findTokens({ orderId }); // Generate QR codes and prepare ticket data const tickets = await Promise.all( tokens.map(async (token) => { const qrCodeUrl = await QRCode.toDataURL(token._id, { width: 300 }); return { tokenId: token._id, eventName: token.meta?.eventName || 'Event', eventDate: token.meta?.eventDate || '', venue: token.meta?.venue || '', seat: token.meta?.seat, qrCodeUrl, }; }) ); return ReactPDF.renderToStream( ); } ``` ## Apple Wallet Pass ### Prerequisites 1. **Apple Developer Account** with Pass Type ID capability 2. **Pass Type ID** registered at [developer.apple.com](https://developer.apple.com/account) 3. **Production Certificate** for your Pass Type ID ### Certificate Setup 1. Create a Pass Type ID in your Apple Developer account 2. Generate and download a production certificate 3. Import into Keychain Access 4. Export as `.p12` file (include both certificate and private key) 5. Convert to PEM format: ```bash openssl pkcs12 -in Certificates.p12 -legacy -clcerts -out cert_and_key.pem ``` ### Environment Variables ```bash PASS_CERTIFICATE_PATH=./cert_and_key.pem PASS_CERTIFICATE_SECRET=YOUR_PEM_PASSPHRASE PASS_TEAM_ID=YOUR_TEAM_ID ``` ### Apple Wallet Renderer ```bash npm install @walletpass/pass-js ``` ```typescript export default async function createAppleWalletPass( token: { _id: string; meta: any }, unchainedAPI: TicketingAPI ) { const template = new Template('eventTicket', { passTypeIdentifier: 'pass.com.yourcompany.tickets', teamIdentifier: process.env.PASS_TEAM_ID, organizationName: 'Your Company', description: 'Event Ticket', foregroundColor: 'rgb(255, 255, 255)', backgroundColor: 'rgb(60, 65, 76)', labelColor: 'rgb(255, 255, 255)', }); // Load certificate await template.loadCertificate( process.env.PASS_CERTIFICATE_PATH, process.env.PASS_CERTIFICATE_SECRET ); // Add images (icon, logo, strip, etc.) await template.images.add('icon', './assets/icon.png'); await template.images.add('logo', './assets/logo.png'); const pass = await template.createPass({ serialNumber: token._id, relevantDate: token.meta?.eventDate, locations: token.meta?.venue ? [{ latitude: token.meta.latitude, longitude: token.meta.longitude, relevantText: token.meta.venue, }] : undefined, }); // Add ticket fields pass.primaryFields.add({ key: 'event', label: 'EVENT', value: token.meta?.eventName || 'Event', }); pass.secondaryFields.add({ key: 'date', label: 'DATE', value: token.meta?.eventDate || '', }); pass.auxiliaryFields.add({ key: 'venue', label: 'VENUE', value: token.meta?.venue || '', }); if (token.meta?.seat) { pass.auxiliaryFields.add({ key: 'seat', label: 'SEAT', value: token.meta.seat, }); } // Add barcode pass.barcodes = [{ format: constants.barcodeFormat.QR, message: token._id, messageEncoding: 'iso-8859-1', }]; return pass; } ``` ## Google Wallet Pass ### Prerequisites 1. **Google Cloud Project** with Wallet API enabled 2. **Service Account** with Wallet Object Creator role 3. **Issuer ID** from Google Pay & Wallet Console ### Environment Variables ```bash GOOGLE_APPLICATION_CREDENTIALS=./service-account.json GOOGLE_WALLET_ISSUER_ID=YOUR_ISSUER_ID ``` ### Google Wallet Renderer ```typescript const issuerId = process.env.GOOGLE_WALLET_ISSUER_ID; const baseUrl = 'https://walletobjects.googleapis.com/walletobjects/v1'; export default async function createGoogleWalletPass( token: { _id: string; meta: any }, unchainedAPI: TicketingAPI ) { const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/wallet_object.issuer'], }); const client = await auth.getClient(); const classId = `${issuerId}.event_${token.meta?.eventId || 'default'}`; const objectId = `${issuerId}.ticket_${token._id}`; // Create or update event class const eventClass = { id: classId, issuerName: 'Your Company', eventName: { defaultValue: { language: 'en', value: token.meta?.eventName || 'Event', }, }, venue: { name: { defaultValue: { language: 'en', value: token.meta?.venue || '', }, }, }, dateTime: { start: token.meta?.eventDate, }, reviewStatus: 'UNDER_REVIEW', }; try { await client.request({ url: `${baseUrl}/eventTicketClass/${classId}`, method: 'GET', }); } catch (err) { // Class doesn't exist, create it await client.request({ url: `${baseUrl}/eventTicketClass`, method: 'POST', data: eventClass, }); } // Create ticket object const ticketObject = { id: objectId, classId: classId, state: 'ACTIVE', ticketHolderName: token.meta?.holderName || '', ticketNumber: token._id, seatInfo: token.meta?.seat ? { seat: { defaultValue: { language: 'en', value: token.meta.seat, }, }, } : undefined, barcode: { type: 'QR_CODE', value: token._id, }, }; // Create JWT for "Add to Google Wallet" URL const credentials = await auth.getCredentials(); const payload = { iss: credentials.client_email, aud: 'google', typ: 'savetowallet', iat: Math.floor(Date.now() / 1000), origins: ['https://yoursite.com'], payload: { eventTicketObjects: [ticketObject], }, }; const privateKey = credentials.private_key; const token_jwt = jwt.sign(payload, privateKey, { algorithm: 'RS256' }); const saveUrl = `https://pay.google.com/gp/v/save/${token_jwt}`; return { asURL: async () => saveUrl, }; } ``` ## Magic Key Order Access Magic keys allow users to access their orders and tickets without logging in - perfect for email links. ### Generate Magic Key ```typescript // In your order confirmation handler const magicKey = await modules.passes.buildMagicKey(orderId); // Include in confirmation email const ticketUrl = `https://my-shop.com/orders/${orderId}?otp=${magicKey}`; ``` ### Use Magic Key in API Requests ```http GET /graphql x-magic-key: YOUR_MAGIC_KEY ``` ### Protected Actions Magic keys provide access to: - `viewOrder` - View order details - `updateToken` - Update token information - `viewToken` - View individual tickets ### Environment Configuration ```bash # Required for magic key encryption UNCHAINED_SECRET=your-secret-key-at-least-32-characters ``` ## API Endpoints The ticketing extension adds these REST endpoints: | Endpoint | Method | Description | |----------|--------|-------------| | `/orders/:orderId/tickets.pdf` | GET | Download PDF tickets | | `/tokens/:tokenId.pkpass` | GET | Download Apple Wallet pass | | `/tokens/:tokenId/google-wallet` | GET | Redirect to Google Wallet | ## GraphQL Integration Query tickets through order items: ```graphql query OrderTickets($orderId: ID!) { order(orderId: $orderId) { _id orderNumber items { _id tokens { _id quantity } } } } ``` ## Frontend Implementation ### Ticket Download Component ```tsx function TicketDownload({ orderId, magicKey }: { orderId: string; magicKey?: string }) { const baseUrl = process.env.NEXT_PUBLIC_API_URL; const pdfUrl = magicKey ? `${baseUrl}/orders/${orderId}/tickets.pdf?otp=${magicKey}` : `${baseUrl}/orders/${orderId}/tickets.pdf`; return ( Download PDF Tickets ); } ``` ### Wallet Pass Buttons ```tsx function WalletButtons({ tokenId, magicKey }: { tokenId: string; magicKey?: string }) { const baseUrl = process.env.NEXT_PUBLIC_API_URL; const queryParams = magicKey ? `?otp=${magicKey}` : ''; return ( ); } ``` ## Testing The ticketing example includes test files: ```bash # Clone the repository git clone https://github.com/unchainedshop/unchained.git # Navigate to ticketing example cd unchained/examples/ticketing # Install dependencies npm install # Run the example npm start ``` ## Best Practices ### 1. Unique Serial Numbers Always use unique token IDs for pass serial numbers to enable updates: ```typescript pass.serialNumber = token._id; ``` ### 2. Relevant Dates Include event dates for lock-screen notifications: ```typescript pass.relevantDate = new Date(token.meta.eventDate); ``` ### 3. Location-Based Notifications Add venue coordinates for location-based pass display: ```typescript pass.locations = [{ latitude: venue.lat, longitude: venue.lng, relevantText: 'Your event is nearby!', }]; ``` ### 4. Pass Updates Implement push notifications for pass updates using Apple's push service. ## Resources - **Ticketing Package**: [github.com/unchainedshop/unchained/tree/master/packages/ticketing](https://github.com/unchainedshop/unchained/tree/master/packages/ticketing) - **Example Implementation**: [github.com/unchainedshop/unchained/tree/master/examples/ticketing](https://github.com/unchainedshop/unchained/tree/master/examples/ticketing) - **Apple Wallet Documentation**: [developer.apple.com/wallet](https://developer.apple.com/wallet/) - **Google Wallet API**: [developers.google.com/wallet](https://developers.google.com/wallet) ## Related - [Warehousing Module](../platform-configuration/modules/warehousing) - Token management - [Order Lifecycle](../concepts/order-lifecycle) - Order processing - [Worker](../extend/worker) - Background job processing --- ## Unchained Engine Documentation Documentation Get started with Unchained Engine Unchained Engine is an open-source, headless e-commerce SDK for Node.js. Build custom storefronts with a fully extensible GraphQL API. Quickstart Set up Unchained Engine in your project. Run Locally Deploy to Railway Create Your First Product Create Your First Order Explore Dive deeper into the platform. Core Concepts Platform Configuration Plugins Extending AI Integration Admin UI --- ## Migrations # Migration Guide All migration instructions are consolidated in a single file: **[MIGRATION.md](https://github.com/unchainedshop/unchained/blob/master/MIGRATION.md)** Covers: v2 β†’ v3 and v3 β†’ v4 ## General Upgrade Process 1. **Read the migration guide** for your target version 2. **Update dependencies** in `package.json` 3. **Start the platform** - database migrations run automatically on startup 4. **Update code** for any breaking API changes 5. **Test thoroughly** before deploying to production ## Getting Help - [GitHub Discussions](https://github.com/unchainedshop/unchained/discussions) - [GitHub Issues](https://github.com/unchainedshop/unchained/issues) - [support@unchained.shop](mailto:support@unchained.shop) for enterprise support --- ## Enable AI Copilot in Unchained Admin UI The Copilot documentation has moved to the [AI Integration section](/ai-integration/admin-copilot). --- ## Environment Variables This document provides a comprehensive list of all environment variables used by Unchained Engine (excluding plugins and ticketing). Most of the plugins and extensions (like ticketing) have their own environment variables, check their docs individually. ## Core Configuration | Variable | Default | Description | |----------|---------|-------------| | `NODE_ENV` | - | Node environment (development, test, production). Affects caching, logging, and other behaviors | | `PORT` | - | Base port number used by the application. MongoDB memory server uses PORT+1 | | `MONGO_URL` | - | MongoDB connection URL. If not set, uses mongodb-memory-server in development/test | | `UNCHAINED_DOCUMENTDB_COMPAT_MODE` | - | Enable AWS DocumentDB compatibility mode (set to any truthy value to enable) | | `UNCHAINED_API_VERSION` | `packageJson.version` | API version returned in GraphQL context, defaults to package.json version | | `UNCHAINED_LANG` | `de` | Default language code | | `UNCHAINED_COUNTRY` | `CH` | Default country code | | `UNCHAINED_CURRENCY` | `CHF` | Default currency code | | `DEBUG` | - | Debug namespace for detailed logging | | `LOG_LEVEL` | `Info` | Log level (Error, Warn, Info, Verbose, Debug) | | `UNCHAINED_LOG_FORMAT` | `unchained` | Log format type | ## Security & Authentication | Variable | Default | Required | Description | |----------|---------|----------|-------------| | `UNCHAINED_SECRET` | - | Yes | Secret key used for signing magic keys and tokens. Must be kept secure | | `UNCHAINED_TOKEN_SECRET` | - | Yes | Secret key for session tokens. Must be at least 32 characters long and kept secret, generate randomly by using `uuidgen` | | `UNCHAINED_COOKIE_NAME` | `unchained_token` | Yes | Name of the session cookie | | `UNCHAINED_COOKIE_PATH` | `/` | Yes |Cookie path | | `UNCHAINED_COOKIE_DOMAIN` | - | No |Cookie domain restriction | | `UNCHAINED_COOKIE_SAMESITE` | `false` | No |SameSite cookie attribute (strict, lax, none, or false) | | `UNCHAINED_COOKIE_INSECURE` | - | No |Allow insecure cookies (set to any truthy value, defaults to secure) | ## Web Configuration | Variable | Default | Required | Description | |----------|---------|----------|-------------| | `ROOT_URL` | `http://localhost:4010` | Yes | Base URL of the application, used for generating absolute URLs | | `EMAIL_WEBSITE_URL` | - | Yes | Frontend website URL, used in email templates and redirects | | `EMAIL_WEBSITE_NAME` | `Unchained` | Yes | Name of the website shown in emails and WebAuthn | ## Email Configuration | Variable | Default | Required | Description | |----------|---------|----------|-------------| | `MAIL_URL` | - | - | SMTP connection URL for sending emails (e.g., `smtp://user:pass@host:port`) | | `EMAIL_FROM` | `noreply@unchained.local` | Yes | Default sender email address | | `EMAIL_ERROR_REPORT_RECIPIENT` | `support@unchained.local` | - | Email address for error reports | | `UNCHAINED_DISABLE_EMAIL_INTERCEPTION` | - | - | Disable email interception in non-production environments (set to any truthy value) | ## API Endpoints | Variable | Default | Description | |----------|---------|-------------| | `BULK_IMPORT_API_PATH` | `/bulk-import` | Bulk import API endpoint path | | `TEMP_UPLOAD_API_PATH` | `/temp-upload` | Temporary file upload API endpoint path | | `MCP_API_PATH` | `/mcp` | Model Context Protocol API endpoint path | | `ERC_METADATA_API_PATH` | `/erc-metadata/:productId/:localeOrTokenFilename/:tokenFileName?` | ERC metadata API endpoint path pattern | ## Admin UI Customization | Variable | Default | Description | |----------|---------|-------------| | `EXTERNAL_LINKS` | - | JSON string containing external links configuration for shop info | ## Worker Configuration | Variable | Default | Description | |----------|---------|-------------| | `UNCHAINED_WORKER_ID` | `os.hostname()` | Unique identifier for worker instance | | `UNCHAINED_DISABLE_WORKER` | - | Disable worker system entirely (set to any truthy value) | | `UNCHAINED_DISABLE_PROVIDER_INVALIDATION` | - | Disable provider invalidation on startup (set to any truthy value) | | `UNCHAINED_ASSIGN_CART_FOR_USERS` | - | Automatically assign carts for users on startup (set to any truthy value) | ## Notes - Environment variables marked as "Required" must be set for the application to start properly - In production, ensure all security-related variables are properly set with strong values - Some variables have different behaviors in development vs production (see `NODE_ENV`) - Email interception is enabled by default in non-production environments unless disabled - Cookie security settings should be carefully configured for production deployments --- ## Platform Configuration To customize an Unchained Engine project, follow these topics: 1. Boot up: Wire Unchained with a web server and boot the app 2. Configure the Core: Configure behavior of the core modules 3. Plugin: Configure which plugins should load 4. Extend ## Boot Configuration The main entry point for an Unchained Engine project is `startPlatform` imported from `@unchainedshop/platform`. Calling it will initialize the Unchained Core, add default messaging templates, and set up the background worker. To make things a bit more simple, Unchained offers different [presets](./plugin-presets.md) for loading functionalities out-of-the box: - `base` (Simple Catalog Price based Pricing strategies, Manual Delivery & Invoice Payment, GridFS Asset Storage) - `crypto` (Currency-Rate Updating Workers for ECB & Coinbase, Currency-Converting Pricing Plugin, Event ERC721 Token Lazy-Minting on Ethereum) - `countries/ch` (Switzerland Tax Calculation and Migros PickMup Integration) - `all` (All of the above + all other available plugins including plugins for various payment gateways) We recommend loading at least `base`. To see it in context, it's best to see an example using Unchained with Fastify, which is being used as a template for [unchainedshop/unchained-app](https://github.com/unchainedshop/unchained-app)-based projects (`boot.ts`): ```ts // Set up the Fastify web server in insecure mode and set the unchained default logger as request logger const fastify = Fastify({ loggerInstance: unchainedLogger("fastify"), disableRequestLogging: true, trustProxy: true, }); try { // Init Unchained Platform with the 'all' plugins preset const platform = await startPlatform({ modules: defaultModules, }); // Use the connect from @unchainedshop/api to connect Unchained to Fastify, setting up the basic endpoints like /graphql connect(fastify, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== "production", initPluginMiddlewares: connectDefaultPluginsToFastify }); // Tell Fastify to start listening on a port, thus accepting connections await fastify.listen({ host: "::", port: process.env.PORT ? parseInt(process.env.PORT) : 3000, }); } catch (err) { fastify.log.error(err); process.exit(1); } ``` To configure various aspects of the platform, `startPlatform` accepts a configuration object with various parameters: - `modules: Record) => any }>`: Modules configuration point. Load your own modules, a preset or a combination of a preset and your own modules. - `services: Record`: Custom services configuration point. Allows you to extend the functionality of the engine with cross-module business-process functions. - `typeDefs`: Object (GraphQL Schema that gets merged with the default schema) - `resolvers`: Object (GraphQL Resolvers that get merged with the default API) - `schema`: Object (GraphQL Executable Schema that gets merged with the default schema, do not use it together with typeDefs & resolvers specified!) - `context`: Special function to extend the underlying [GraphQL context](https://the-guild.dev/graphql/yoga-server/docs/features/context). Check the [OIDC Example](https://github.com/unchainedshop/unchained/blob/master/examples/oidc/boot.ts) for how you could use it to add custom Auth functionality. - `options`: Module-specific configuration options (see [Module Options](#module-options) below) - `rolesOptions`: `IRoleOptionConfig`: Enables you to customize the existing roles and actions, adjusting fine-grained permissions. - `bulkImporter`: Enables you to define custom bulk import handlers for a clear separation of data import and e-commerce engine. For more information about the bulk import API, refer to the [Bulk Import Guide](../guides/bulk-import). - `workQueueOptions`: `SetupWorkqueueOptions` Configuration regarding the work queue, for example disabling it entirely in multi-pod setups - `adminUiConfig`: Customize the Unchained Admin UI, for example configuring a Single-Sign-On Link for external Auth support via oAuth. ## Module Options The `options` parameter accepts module-specific configuration: ```typescript await startPlatform({ options: { // Assortments module assortments: { slugify: (title) => customSlugify(title), }, // Products module products: { slugify: (title) => customSlugify(title), }, // Delivery module delivery: { filterSupportedProviders: async ({ providers, order }, unchainedAPI) => providers, determineDefaultProvider: async ({ providers, order }, unchainedAPI) => providers[0], }, // Payment module payment: { filterSupportedProviders: async ({ providers, order }, unchainedAPI) => providers, determineDefaultProvider: async ({ providers, order }, unchainedAPI) => providers[0], }, // Orders module orders: { ensureUserHasCart: true, orderNumberHashFn: (order, index) => `ORD-${index}`, lockOrderDuringCheckout: true, }, // Users module users: { mergeUserCartsOnLogin: true, autoMessagingAfterUserCreation: true, validatePassword: async (password) => password.length >= 8, validateEmail: async (email) => email.includes('@'), }, // Enrollments module enrollments: { enrollmentNumberHashFn: (enrollment, index) => `ENR-${index}`, }, // Quotations module quotations: { quotationNumberHashFn: (quotation, index) => `QUO-${index}`, }, // Files module files: { transformUrl: (url, params) => url, privateFileSharingMaxAge: 3600, }, // Worker module worker: { blacklistedVariables: ['SECRET_KEY'], }, }, }); ``` For detailed documentation of each module's options, see the respective module documentation under [Modules](./modules/). These options are extended by `YogaServerOptions` so you can pass all options you can normally pass to `createYoga`, add plugins [Yoga GraphQL Plugins](https://the-guild.dev/graphql/yoga-server/docs/features/envelop-plugins) or configure [batching](https://the-guild.dev/graphql/yoga-server/docs/features/request-batching) and other more advanced GraphQL features. Check the [Yoga documentation](https://the-guild.dev/graphql/yoga-server/docs) for more information. ## Getting Help - πŸ“š [Full Documentation](/) - πŸ’¬ [GitHub Discussions](https://github.com/unchainedshop/unchained/discussions) - πŸ› [Report Issues](https://github.com/unchainedshop/unchained/issues) - πŸ“§ [Contact Support](mailto:support@unchained.shop) --- ## Messaging Unchained Engine includes an event-driven messaging system that sends notifications via email or SMS. Messages are processed asynchronously through the worker queue. ## Architecture ```mermaid flowchart LR E[Platform Event] -->|triggers| M[MESSAGE Work] M -->|resolves template| T[TemplateResolver] T -->|creates| W1[EMAIL Work] T -->|creates| W2[SMS Work] W1 -->|processed by| WK1[Email Worker] W2 -->|processed by| WK2[SMS Worker] ``` 1. A platform event (e.g., `ORDER_CONFIRMED`) triggers a `MESSAGE` work item 2. The Message Worker resolves the template registered for that message type 3. The template resolver returns one or more concrete work items (EMAIL, SMS, etc.) 4. Each work item is processed by the corresponding worker adapter ## Built-in Message Types Unchained registers 7 default message templates: | Template | Trigger | Description | |----------|---------|-------------| | `ACCOUNT_ACTION` | User registration, password reset, email verification | Account lifecycle emails with action URLs | | `ORDER_CONFIRMATION` | `ORDER_CHECKOUT`, `ORDER_CONFIRMED` events | Order confirmation sent to the customer | | `ORDER_REJECTION` | `ORDER_REJECTED` event | Order rejection notification | | `DELIVERY` | `ORDER_CONFIRMED` event | Forwards order details to internal recipients (warehouse, support) | | `QUOTATION_STATUS` | Quotation status changes | Quotation update notification | | `ENROLLMENT_STATUS` | Enrollment status changes | Subscription status notification | | `ERROR_REPORT` | Worker failures | Sends failed work items to support team | ### ACCOUNT_ACTION Handles all user account lifecycle emails: | Action | When | Content | |--------|------|---------| | `enroll-account` | New user enrollment | Welcome email with setup link | | `reset-password` | Password reset request | Reset link with token | | `verify-email` | Email verification | Verification link | | *(empty)* | Password changed | Confirmation notice | Input: `{ userId, action, recipientEmail, token }` ### ORDER_CONFIRMATION Sent when an order transitions past PENDING status. Includes order details, items, pricing, and delivery info. Input: `{ orderId, locale }` ### DELIVERY Forwards order information to internal recipients (e.g., warehouse staff). Configured via the delivery provider's configuration keys: ```graphql mutation { createDeliveryProvider( deliveryProvider: { type: SHIPPING adapterKey: "shop.unchained.delivery.send-message" configuration: [ { key: "from", value: "shop@example.com" } { key: "to", value: "warehouse@example.com" } { key: "cc", value: "logistics@example.com" } ] } ) { _id } } ``` ### ERROR_REPORT Automatically sends failed work items to the address configured in `ERROR_REPORT_RECIPIENT` environment variable. ## Custom Templates ### 1. Implement a TemplateResolver A template resolver is a function that transforms input data into one or more message work configurations: ```typescript const myTemplate: TemplateResolver = async ( { userId, orderId, customData }, unchainedAPI ) => { const { modules } = unchainedAPI; const user = await modules.users.findUserById(userId); const email = modules.users.primaryEmail(user); return [ { type: 'EMAIL', input: { from: 'shop@example.com', to: email.address, subject: 'Your custom notification', text: `Hello ${user.profile?.address?.firstName}, ${customData}`, html: `Hello ${user.profile?.address?.firstName}, ${customData}`, }, }, ]; }; ``` ### 2. Register the Template ```typescript MessagingDirector.registerTemplate('MY_CUSTOM_TEMPLATE', myTemplate); ``` ### 3. Trigger the Message Add a `MESSAGE` work item to the queue: ```typescript await modules.worker.addWork({ type: 'MESSAGE', retries: 0, input: { template: 'MY_CUSTOM_TEMPLATE', userId, orderId, customData: 'Your order has been updated.', }, }); ``` ## Overriding Built-in Templates Register a template with the same name as a built-in type to override it: ```typescript MessagingDirector.registerTemplate('ORDER_CONFIRMATION', async ({ orderId, locale }, api) => { const order = await api.modules.orders.findOrder({ orderId }); const user = await api.modules.users.findUserById(order.userId); const email = api.modules.users.primaryEmail(user); return [ { type: 'EMAIL', input: { from: 'shop@example.com', to: email.address, subject: `Order #${order.orderNumber} confirmed`, html: renderOrderEmail(order), // Your custom rendering }, }, ]; }); ``` ## Email Attachments Email templates support three attachment formats: ```typescript return [ { type: 'EMAIL', input: { from: 'shop@example.com', to: 'customer@example.com', subject: 'Your invoice', text: 'Please find your invoice attached.', attachments: [ // File path { filename: 'invoice.pdf', path: '/tmp/invoice-123.pdf' }, // Inline content (base64) { filename: 'data.csv', content: Buffer.from(csvData).toString('base64'), contentType: 'text/csv', encoding: 'base64', }, // URL reference { filename: 'receipt.pdf', href: 'https://example.com/receipts/123.pdf' }, ], }, }, ]; ``` ## Multi-Channel Messages A single template can return multiple work items for different channels: ```typescript const orderAlert: TemplateResolver = async ({ orderId }, api) => { const order = await api.modules.orders.findOrder({ orderId }); return [ { type: 'EMAIL', input: { to: 'admin@example.com', subject: `New order #${order.orderNumber}`, text: `Order total: ${order.pricing?.total}`, }, }, { type: 'TWILIO', input: { to: '+41791234567', text: `New order #${order.orderNumber}`, }, }, ]; }; ``` ## SMS Providers ### Twilio Environment variables: `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_SMS_FROM` ```typescript { type: 'TWILIO', input: { to: '+41791234567', text: 'Hello!' } } ``` ### BulkGate Environment variables: `BULKGATE_APPLICATION_ID`, `BULKGATE_APPLICATION_TOKEN` ```typescript { type: 'BULKGATE', input: { to: '+41791234567', text: 'Hello!' } } ``` ### BudgetSMS Environment variables: `BUDGETSMS_USERNAME`, `BUDGETSMS_USERID`, `BUDGETSMS_HANDLE` ```typescript { type: 'BUDGETSMS', input: { to: '+41791234567', text: 'Hello!' } } ``` ## Email Configuration ### Production Set the `MAIL_URL` environment variable to your SMTP server: ```bash MAIL_URL=smtp://user:password@smtp.example.com:587 ``` ### Development In non-production mode, emails are intercepted and opened in the browser for preview. Disable this with: ```bash UNCHAINED_DISABLE_EMAIL_INTERCEPTION=1 ``` ## MessagingDirector API ```typescript // Register a template MessagingDirector.registerTemplate(name: string, resolver: TemplateResolver): void // Get a registered template resolver MessagingDirector.getTemplate(name: string): TemplateResolver | undefined // List all registered template names MessagingDirector.getRegisteredTemplates(): string[] ``` ## Related - [Email Worker](../plugins/workers/worker-email.md) - Email delivery plugin - [Twilio Worker](../plugins/workers/twilio.md) - SMS via Twilio - [BulkGate Worker](../plugins/workers/worker-bulkgate.md) - SMS via BulkGate - [BudgetSMS Worker](../plugins/workers/worker-budgetsms.md) - SMS via BudgetSMS - [Worker Module](./modules/worker.md) - Background job processing --- ## Assortments Module The assortments module manages product categorization, hierarchical structures, and product caching. ## Configuration Options ```typescript export interface AssortmentsSettingsOptions { getCachedProductIds: ( assortmentId: string, ) => Promise; setCachedProductIds: ( assortmentId: string, productIds: string[], ) => Promise; slugify: (title: string) => string; zipTree: (data: Tree) => string[]; } ``` ### Default Slugifier - [slugify](https://github.com/unchainedshop/unchained/blob/master/packages/utils/src/slugify.ts) ### Default Caching Implementation - [mongodb](https://github.com/unchainedshop/unchained/blob/master/packages/core-assortments/src/product-cache/mongodb.ts) ### Built-in Tree Zippers - [zipTreeByDeepness](https://github.com/unchainedshop/unchained/blob/master/packages/core-assortments/src/utils/tree-zipper/zipTreeByDeepness.ts) (default) - Interleaves products by depth - [zipTreeBySimplyFlattening](https://github.com/unchainedshop/unchained/blob/master/packages/core-assortments/src/utils/tree-zipper/zipTreeBySimplyFlattening.ts) - Flattens assortments sequentially ## Product Caching and Tree Zipping ### Overview Assortments can form hierarchical structures (directed acyclic graphs). When products are added or modified, the system automatically recalculates cached product lists for all parent assortments. ### Example Structure Consider this assortment hierarchy: ``` - A - A1 - A2 - SPECIAL - B - SPECIAL ``` When a product is added to `SPECIAL`, the system recalculates product caches for `SPECIAL`, `A2`, `A`, and `B`. ### Default Behavior (zipTreeByDeepness) The default `zipTreeByDeepness` function interleaves products by depth level: ``` 1. Products directly linked to A 2. Product 1 from A1 3. Product 1 from A2 4. Product 2 from A1 5. Product 2 from A2 ... ``` This creates a deterministic but mixed ordering across all child assortments. ### Custom Ordering To order products sequentially by assortment instead, use `zipTreeBySimplyFlattening`: ```typescript const options = { modules: { assortments: { zipTree: zipTreeBySimplyFlattening, }, }, }; ``` This produces sequential ordering: ``` 1. Products directly linked to A 2. All products from A1 3. All products from A2 ``` ### Cache Integration After calculating the product order, the system calls `setCachedProductIds` to store the result. This cached data is retrieved via `getCachedProductIds` and powers the `Assortment.searchProducts` GraphQL field with default sorting. :::warning If you customize `setCachedProductIds`, ensure you also customize `getCachedProductIds`. ::: ## Events | Event | Payload | Description | |-------|---------|-------------| | `ASSORTMENT_CREATE` | `{ assortment }` | Emitted when an assortment is created | | `ASSORTMENT_UPDATE` | `{ assortmentId }` | Emitted when an assortment is updated | | `ASSORTMENT_REMOVE` | `{ assortmentId }` | Emitted when an assortment is removed | | `ASSORTMENT_ADD_PRODUCT` | `{ assortmentProduct }` | Emitted when a product is added | | `ASSORTMENT_REMOVE_PRODUCT` | `{ assortmentProductId }` | Emitted when a product is removed | | `ASSORTMENT_REORDER_PRODUCTS` | `{ assortmentProducts }` | Emitted when products are reordered | | `ASSORTMENT_ADD_LINK` | `{ assortmentLink }` | Emitted when a link is added | | `ASSORTMENT_REMOVE_LINK` | `{ assortmentLinkId }` | Emitted when a link is removed | | `ASSORTMENT_ADD_FILTER` | `{ assortmentFilter }` | Emitted when a filter is added | | `ASSORTMENT_REMOVE_FILTER` | `{ assortmentFilterId }` | Emitted when a filter is removed | | `ASSORTMENT_REORDER_FILTERS` | `{ assortmentFilters }` | Emitted when filters are reordered | | `ASSORTMENT_ADD_MEDIA` | `{ assortmentMedia }` | Emitted when media is added | | `ASSORTMENT_REMOVE_MEDIA` | `{ assortmentMediaId }` | Emitted when media is removed | | `ASSORTMENT_REORDER_MEDIA` | `{ assortmentMedias }` | Emitted when media is reordered | | `ASSORTMENT_UPDATE_TEXT` | `{ assortmentId, locale }` | Emitted when text is updated | | `ASSORTMENT_UPDATE_MEDIA_TEXT` | `{ assortmentMediaId }` | Emitted when media text is updated | ## More Information For API usage and detailed documentation, see the [core-assortments package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-assortments). --- ## Bookmarks Module The bookmarks module allows users to save products for later reference, implementing wishlist functionality. ## Configuration Options The bookmarks module has no configuration options. ## Module API Access via `modules.bookmarks` in the Unchained API context. ### Queries | Method | Arguments | Description | |--------|-----------|-------------| | `findBookmarkById` | `bookmarkId` | Find a specific bookmark | | `findBookmarksByUserId` | `userId` | Get all bookmarks for a user | | `findBookmarks` | `query` | Find bookmarks by custom filter | ### Mutations | Method | Arguments | Description | |--------|-----------|-------------| | `create` | `doc` | Create a new bookmark | | `update` | `bookmarkId, doc` | Update bookmark | | `delete` | `bookmarkId` | Delete bookmark | | `deleteByUserId` | `userId` | Delete all bookmarks for a user | | `deleteByProductId` | `productId` | Delete all bookmarks for a product | | `replaceUserId` | `fromUserId, toUserId, bookmarkIds?` | Migrate bookmarks between users (used during guest-to-registered conversion) | ### Usage ```typescript // Create a bookmark await modules.bookmarks.create({ userId: 'user-123', productId: 'product-456', }); // Get user's wishlist const wishlist = await modules.bookmarks.findBookmarksByUserId('user-123'); // Migrate bookmarks when guest converts to registered user await modules.bookmarks.replaceUserId('guest-id', 'registered-id'); ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `BOOKMARK_CREATE` | `{ bookmarkId }` | Emitted when a bookmark is created | | `BOOKMARK_UPDATE` | `{ bookmarkId }` | Emitted when a bookmark is updated | | `BOOKMARK_REMOVE` | `{ bookmarkId }` | Emitted when a bookmark is removed | ## Related - [Authentication](../../concepts/authentication.md) - Guest-to-registered conversion --- ## Countries Module The countries module manages supported countries for shipping, billing, and localization. ## Configuration Options The countries module has no configuration options. ## Module API Access via `modules.countries` in the Unchained API context. ### Queries | Method | Arguments | Description | |--------|-----------|-------------| | `findCountry` | `{ countryId? \| isoCode? }` | Find country by ID or ISO code | | `findCountries` | `{ limit?, offset?, sort?, ...query }` | List countries with pagination | | `count` | `query` | Count countries matching criteria | | `countryExists` | `{ countryId }` | Check if country exists | | `name` | `country, locale` | Get localized country name | | `flagEmoji` | `country` | Get country flag emoji | | `isBase` | `country` | Check if this is the default country | ### Mutations | Method | Arguments | Description | |--------|-----------|-------------| | `create` | `doc` | Create a new country | | `update` | `countryId, doc` | Update country | | `delete` | `countryId` | Delete country | ### Usage ```typescript // Find a country by ISO code const country = await modules.countries.findCountry({ isoCode: 'CH' }); // Get localized name const name = modules.countries.name(country, 'en'); // "Switzerland" // Get flag emoji const flag = modules.countries.flagEmoji(country); // "πŸ‡¨πŸ‡­" // List all active countries const countries = await modules.countries.findCountries({ includeInactive: false, }); ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `COUNTRY_CREATE` | `{ countryId }` | Emitted when a country is created | | `COUNTRY_UPDATE` | `{ countryId }` | Emitted when a country is updated | | `COUNTRY_REMOVE` | `{ countryId }` | Emitted when a country is removed | ## Related - [Multi-Language Setup](../../guides/multi-language-setup.md) - Localization guide - [Seed Data](../../guides/seed-data.md) - Bootstrap countries --- ## Currencies Module The currencies module manages supported currencies for pricing and transactions. ## Configuration Options The currencies module has no configuration options. ## Module API Access via `modules.currencies` in the Unchained API context. ### Queries | Method | Arguments | Description | |--------|-----------|-------------| | `findCurrency` | `{ isoCode? \| currencyId? }` | Find currency by ISO code or ID | | `findCurrencies` | `{ limit?, offset?, sort?, ...query }` | List currencies with pagination | | `count` | `query` | Count currencies matching criteria | | `currencyExists` | `{ currencyId }` | Check if currency exists | ### Mutations | Method | Arguments | Description | |--------|-----------|-------------| | `create` | `doc` | Create a new currency | | `update` | `currencyId, doc` | Update currency | | `delete` | `currencyId` | Delete currency | ### Usage ```typescript // Find a currency const chf = await modules.currencies.findCurrency({ isoCode: 'CHF' }); // List active currencies const currencies = await modules.currencies.findCurrencies({ includeInactive: false, }); // Create a new currency await modules.currencies.create({ isoCode: 'EUR', isActive: true, }); ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `CURRENCY_CREATE` | `{ currencyId }` | Emitted when a currency is created | | `CURRENCY_UPDATE` | `{ currencyId }` | Emitted when a currency is updated | | `CURRENCY_REMOVE` | `{ currencyId }` | Emitted when a currency is removed | ## Related - [Multi-Currency Setup](../../guides/multi-currency-setup.md) - Multi-currency guide - [Pricing System](../../concepts/pricing-system.md) - How pricing works --- ## Delivery Module The delivery module manages delivery provider selection and configuration. ## Configuration Options ```typescript export type FilterProvider = ( params: { providers: DeliveryProvider[]; order: Order; }, unchainedAPI: UnchainedAPI, ) => Promise; export type DetermineDefaultProvider = ( params: { providers: DeliveryProvider[]; order: Order; }, unchainedAPI: UnchainedAPI, ) => Promise; export interface DeliverySettingsOptions { filterSupportedProviders?: FilterProviders; determineDefaultProvider?: DetermineDefaultProvider; } ``` ### Custom Filtering ```typescript const options = { modules: { delivery: { filterSupportedProviders: ({ order, providers }) => { return providers .toSorted((left, right) => { return new Date(left.created).getTime() - new Date(right.created).getTime(); }) .filter((provider) => { return process.env.NODE_ENV === 'production' ? provider._id !== 'free' : true; }); }, }, }, }; ``` By default we return all providers based on the creation date and don't filter any. You can't return inactive delivery providers in general. ### Default Provider Selection for New Orders ```typescript const options = { modules: { delivery: { determineDefaultProvider: ({ order, providers }) => { return providers?.find(({ _id }) => _id === 'this-id-always-default'); }, }, }, }; ``` By default the default provider is defined as first in list of providers (transformed by `filterSupportedProviders` first). ## Events | Event | Payload | Description | |-------|---------|-------------| | `DELIVERY_PROVIDER_CREATE` | `{ deliveryProvider }` | Emitted when a delivery provider is created | | `DELIVERY_PROVIDER_UPDATE` | `{ deliveryProvider }` | Emitted when a delivery provider is updated | | `DELIVERY_PROVIDER_REMOVE` | `{ deliveryProvider }` | Emitted when a delivery provider is removed | ## More Information For API usage and detailed documentation, see the [core-delivery package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-delivery). --- ## Enrollments Module The enrollments module manages subscriptions and recurring orders. ## Configuration Options ```typescript export interface EnrollmentsSettingsOptions { autoSchedulingSchedule?: ScheduleData; enrollmentNumberHashFn?: (enrollment: Enrollment, index: number) => string; } ``` ### Invoice Generator Schedule Interval that the enrollment generator tries to generate new invoices, default behaviour: ```typescript const defaultSchedule = schedule.parse.text('every 59 minutes'); ``` This does not control if a new invoice actually is created, that is based on the enrollment plugin implementation and state of the user's enrollment. ### Enrollment Number Creation The `enrollmentNumberHashFn` is used to generate human-readable codes that can be easily spelled out to support staff. The default is a hashids based function that generates an alphanumeric uppercase string with length 6 without the hard to distinguish 0IOl etc. If the number has already been taken, the function gets iteratively called with an increasing `index`. [Default Random Hash Generator](https://github.com/unchainedshop/unchained/blob/master/packages/utils/src/generate-random-hash.ts) ### Example Custom Configuration ```typescript const options = { modules: { enrollments: { autoSchedulingSchedule: schedule.parse.text('every 7 days'), enrollmentNumberHashFn: (enrollment, index) => enrollment.sequence + 300000 + index, }, }, }; ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `ENROLLMENT_CREATE` | `{ enrollment }` | Emitted when an enrollment is created | | `ENROLLMENT_UPDATE` | `{ enrollment, field }` | Emitted when an enrollment is updated | | `ENROLLMENT_REMOVE` | `{ enrollmentId }` | Emitted when an enrollment is removed | | `ENROLLMENT_ADD_PERIOD` | `{ enrollment }` | Emitted when a period is added to an enrollment | ## More Information For API usage and detailed documentation, see the [core-enrollments package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-enrollments). --- ## Events Module The events module persists all emitted events to the database for auditing, analytics, and event sourcing patterns. ## Configuration Options The events module has no configuration options. ## Module API Access via `modules.events` in the Unchained API context. ### Queries | Method | Arguments | Description | |--------|-----------|-------------| | `findEvent` | `{ eventId }, options?` | Find a specific event | | `findEvents` | `{ limit?, offset?, ...query }` | List events with pagination | | `count` | `query` | Count events matching criteria | | `getReport` | `{ dateRange?, types? }` | Get aggregated event statistics | ### Usage ```typescript // List recent order events const events = await modules.events.findEvents({ types: ['ORDER_CREATE', 'ORDER_CHECKOUT', 'ORDER_CONFIRMED'], limit: 50, }); // Get event statistics for a date range const report = await modules.events.getReport({ types: ['ORDER_CONFIRMED'], dateRange: { start: new Date('2024-01-01'), end: new Date('2024-12-31'), }, }); // Count specific events const count = await modules.events.count({ types: ['PRODUCT_UPDATE'], }); ``` ## Events The events module does not emit events itself β€” it receives and stores events emitted by all other modules. ## Event Types Every module emits events for state changes. Common patterns: | Module | Events | |--------|--------| | Orders | `ORDER_CREATE`, `ORDER_UPDATE`, `ORDER_CHECKOUT`, `ORDER_CONFIRMED`, `ORDER_FULFILLED`, `ORDER_REJECTED` | | Products | `PRODUCT_CREATE`, `PRODUCT_UPDATE`, `PRODUCT_REMOVE`, `PRODUCT_PUBLISH`, `PRODUCT_UNPUBLISH` | | Users | `USER_CREATE`, `USER_UPDATE`, `USER_REMOVE` | See individual module documentation for complete event lists. ## Related - [Events Extension](../../extend/events.md) - Custom event listeners - [Event Plugins](../../plugins/events/index.md) - Event transport adapters --- ## Files Module The files module manages file storage, URL transformation, and private file sharing. ## Configuration Options ```typescript export interface FilesSettingsOptions { transformUrl?: (url: string, params: Record) => string; privateFileSharingMaxAge?: number; // milliseconds } ``` ### URL Transformation By transforming URLs for media, you can enable direct delivery from CDN systems or add thumbnailing system support. Here is an example for a custom `transformUrl` implementation adding ad-hoc thumbnailing through Thumbor: ```typescript const getNormalizedUrl = (url, myHostname = os.hostname()) => { try { // If the url is absolute, return const finalURL = new URL(url); return finalURL.href; } catch { try { // else try to fix by using hostname (for GridFS) const tempURL = new URL(url, `http://0.0.0.0`); return `${myHostname}:${process.env.PORT}${tempURL.pathname}`; } catch { // else return the transformed string because it's not an URL return url; } } }; const { THUMBOR_SECRET, THUMBOR_ENDPOINT } = process.env; const ThumbnailSizes = { small: ['150x'], medium: ['600x'], large: ['2048x'], }; export const transformUrlWithThumbor = (url, params) => { if (THUMBOR_ENDPOINT && THUMBOR_SECRET) { const normalizedUrl = getNormalizedUrl(url); const parameters = ThumbnailSizes[params?.version?.toLowerCase()]; if (parameters) { const unsafePath = `${parameters.join('/')}/${encodeURIComponent(normalizedUrl)}`; const hash = crypto.createHmac('sha1', THUMBOR_SECRET).update(unsafePath).digest('base64'); const safeHash = hash.replace(/\+/gi, '-').replace(/\//gi, '_'); const safeUrl = `${THUMBOR_ENDPOINT}/${safeHash}/${unsafePath}`; return safeUrl; } } return url; }; export default transformUrlWithThumbor; ``` ### Enable Private File Sharing Private File Sharing by default is allowed for 24 hours. Private File Sharing is not used by default and allows a developer to create signed download URLs for special cases. ## Events | Event | Payload | Description | |-------|---------|-------------| | `FILE_CREATE` | `{ fileId }` | Emitted when a file is created | | `FILE_UPDATE` | `{ fileId }` | Emitted when a file is updated | | `FILE_REMOVE` | `{ fileId }` | Emitted when a file is removed | ## More Information For API usage and detailed documentation, see the [core-files package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-files). --- ## Filters Module The filters module manages product filtering and faceted search capabilities. ## Configuration Options ```typescript export interface FilterSettingsOptions { setCachedProductIds?: ( filterId: string, productIds: string[], productIdsMap: Record, ) => Promise; getCachedProductIds?: (filterId: string) => Promise<[string[], Record] | null>; } ``` ### Default Caching Implementation - [mongodb](https://github.com/unchainedshop/unchained/blob/master/packages/core-filters/src/product-cache/mongodb.ts) :::warning If you customize `setCachedProductIds`, ensure you also customize `getCachedProductIds`. ::: ## Events | Event | Payload | Description | |-------|---------|-------------| | `FILTER_CREATE` | `{ filter }` | Emitted when a filter is created | | `FILTER_UPDATE` | `{ filterId, options, updated }` | Emitted when a filter is updated | | `FILTER_REMOVE` | `{ filterId }` | Emitted when a filter is removed | | `FILTER_UPDATE_TEXT` | `{ filterId, locale }` | Emitted when filter text is updated | ## More Information For API usage and detailed documentation, see the [core-filters package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-filters). --- ## Core Modules Unchained Engine is built around a modular architecture. Each core module handles a specific domain of e-commerce functionality. ## Module Overview | Module | Package | Description | |--------|---------|-------------| | [Products](./products) | `core-products` | Product catalog management | | [Orders](./orders) | `core-orders` | Order lifecycle and cart | | [Users](./users) | `core-users` | User accounts and authentication | | [Assortments](./assortments) | `core-assortments` | Category hierarchies | | [Filters](./filters) | `core-filters` | Search and faceted filtering | | [Payment](./payment) | `core-payment` | Payment providers | | [Delivery](./delivery) | `core-delivery` | Delivery providers | | [Warehousing](./warehousing) | `core-warehousing` | Inventory management | | [Enrollments](./enrollments) | `core-enrollments` | Subscriptions | | [Quotations](./quotations) | `core-quotations` | Quote requests | | [Bookmarks](./bookmarks) | `core-bookmarks` | User favorites | | [Files](./files) | `core-files` | Media management | | [Worker](./worker) | `core-worker` | Background jobs | | [Events](./events) | `core-events` | Event history | | [Countries](./countries) | `core-countries` | Country configuration | | [Currencies](./currencies) | `core-currencies` | Currency configuration | | [Languages](./languages) | `core-languages` | Language configuration | ## Configuration Configure module options when starting the platform: ```typescript await startPlatform({ options: { // Module-specific options orders: { ensureUserHasCart: true, }, products: { slugify: (title) => title.toLowerCase().replace(/\s+/g, '-'), }, users: { mergeUserCartsOnLogin: true, }, }, }); ``` ## Accessing Modules Modules are available through the `modules` context: ```typescript // In GraphQL resolvers const resolvers = { Query: { product: async (_, { productId }, { modules }) => { return modules.products.findProduct({ productId }); }, }, }; // In custom code after platform start const { modules } = await startPlatform({ ... }); const products = await modules.products.findProducts({ status: 'ACTIVE', limit: 10, }); ``` ## Common Module Methods Most modules follow a consistent pattern: ### Query Methods ```typescript // Find single entity modules.products.findProduct({ productId }); // Find multiple entities modules.products.findProducts({ status: 'ACTIVE', limit: 10 }); // Count entities modules.products.count({ status: 'ACTIVE' }); // Check existence modules.products.productExists({ productId }); ``` ### Mutation Methods ```typescript // Create const productId = await modules.products.create({ type: 'SIMPLE' }); // Update await modules.products.update(productId, { status: 'ACTIVE' }); // Delete (usually soft delete) await modules.products.delete(productId); ``` ## Events Modules emit events for important operations. Subscribe to events for custom logic: ```typescript // Register custom event handlers registerEvents(['CUSTOM_EVENT']); // Subscribe to events events.on('PRODUCT_CREATE', async ({ payload }) => { console.log('Product created:', payload.productId); }); ``` Common event patterns: - `{MODULE}_CREATE` - Entity created - `{MODULE}_UPDATE` - Entity updated - `{MODULE}_REMOVE` - Entity deleted ## Related - [Architecture](../../concepts/architecture) - System architecture overview - [Director/Adapter Pattern](../../concepts/director-adapter-pattern) - Plugin system - [Extending GraphQL](../../extend/graphql) - Custom API extensions --- ## Languages Module The languages module manages supported languages for multi-language content. ## Configuration Options The languages module has no configuration options. ## Module API Access via `modules.languages` in the Unchained API context. ### Queries | Method | Arguments | Description | |--------|-----------|-------------| | `findLanguage` | `{ languageId? \| isoCode? }` | Find language by ID or ISO code | | `findLanguages` | `{ limit?, offset?, sort?, ...query }` | List languages with pagination | | `count` | `query` | Count languages matching criteria | | `languageExists` | `{ languageId }` | Check if language exists | | `isBase` | `language` | Check if this is the default language | ### Mutations | Method | Arguments | Description | |--------|-----------|-------------| | `create` | `doc` | Create a new language | | `update` | `languageId, doc` | Update language | | `delete` | `languageId` | Delete language | ### Usage ```typescript // Find a language const german = await modules.languages.findLanguage({ isoCode: 'de' }); // Check if it's the base language const isDefault = modules.languages.isBase(german); // List all active languages const languages = await modules.languages.findLanguages({ includeInactive: false, }); ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `LANGUAGE_CREATE` | `{ languageId }` | Emitted when a language is created | | `LANGUAGE_UPDATE` | `{ languageId }` | Emitted when a language is updated | | `LANGUAGE_REMOVE` | `{ languageId }` | Emitted when a language is removed | ## Related - [Multi-Language Setup](../../guides/multi-language-setup.md) - Multi-language guide - [Seed Data](../../guides/seed-data.md) - Bootstrap languages --- ## Orders Module The orders module manages the order lifecycle, cart operations, and checkout process. ## Configuration Options ```typescript export interface OrderSettingsOrderPositionValidation { order: Order; product: Product; quantityDiff?: number; configuration?: { key: string; value: string }[]; } export interface OrdersSettingsOptions { ensureUserHasCart?: boolean; orderNumberHashFn?: (order: Order, index: number) => string; validateOrderPosition?: ( validationParams: OrderSettingsOrderPositionValidation, unchainedAPI: UnchainedAPI, ) => Promise; lockOrderDuringCheckout?: boolean; } ``` - `ensureUserHasCart`: If enabled, Unchained will try to pre-generate a new cart when a user does not have one on various occasions, it's still not guaranteed that a user always has a cart. (default: false) - `lockOrderDuringCheckout`: If enabled, Unchained tries to use a so-called "Distributed Locking" approach with MongoDB while the checkout process is running, highly encouraged for most cases (default: true) ### Order Number Creation The `orderNumberHashFn` is used to generate human-readable codes that can be easily spelled out to support staff. The default is a hashids based function that generates an alphanumeric uppercase string with length 6 without the hard to distinguish 0IOl etc. If the number has already been taken, the function gets iteratively called with an increasing `index`. [Default Random Hash Generator](https://github.com/unchainedshop/unchained/blob/master/packages/utils/src/generate-random-hash.ts) ### Order Position Validation When users mutate their cart, you sometimes have cases where you want custom checks. For example you want to restrict users to only buy 4 tickets in one go despite having a stock of 100 tickets. With `validateOrderPosition` you can validate cart manipulations and throw Errors that bubble through to the client. **The default validator checks if a product is active.** ```typescript const options = { modules: { orders: { orderNumberHashFn: (order, index) => order.sequence + 100000 + index, validateOrderPosition: async ({ order, product, quantityDiff, configuration }, context) => { const justOneAtATime = product.tags?.includes('one-at-a-time'); const positions = await context.modules.orders.positions.findOrderPositions({ orderId: order._id, }); const userAlreadyHasProductInPositions = positions.some((p) => p.productId === product._id); if (justOneAtATime && userAlreadyHasProductInPositions && quantityDiff > 0) { throw new Error('ONE_AT_A_TIME'); } }, }, }, }; ``` ## Module API Access via `modules.orders` in the Unchained API context. ### Queries | Method | Arguments | Description | |--------|-----------|-------------| | `cart` | `{ userId, countryCode?, orderNumber? }` | Get user's cart | | `findOrder` | `{ orderId? \| orderNumber? }, options?` | Find a specific order | | `findOrders` | `{ limit?, offset?, sort?, ...query }` | List orders with pagination | | `findOrderIds` | `query` | Get array of order IDs | | `count` | `query` | Count orders | | `orderExists` | `{ orderId }` | Check if order exists | | `isCart` | `order` | Check if order is a cart | ### Mutations | Method | Arguments | Description | |--------|-----------|-------------| | `create` | `{ userId, countryCode, currencyCode, billingAddress?, contact?, context? }` | Create order | | `delete` | `orderId` | Delete order | | `setCartOwner` | `{ orderId, userId }` | Change order owner | | `updateBillingAddress` | `orderId, billingAddress` | Update billing address | | `updateContact` | `orderId, contact` | Update contact info | | `updateContext` | `orderId, context` | Update order context/meta | | `updateStatus` | `orderId, { status, info? }` | Update order status | | `setPaymentProvider` | `orderId, paymentProviderId` | Set payment method | | `setDeliveryProvider` | `orderId, deliveryProviderId` | Set delivery method | | `acquireLock` | `orderId, identifier, timeout?` | Acquire distributed lock | ### Sub-modules **`modules.orders.positions`** β€” Order line items: | Method | Arguments | Description | |--------|-----------|-------------| | `findOrderPosition` | `{ itemId }` | Get single position | | `findOrderPositions` | `{ orderId }` | Get all positions in order | | `addProductItem` | `{ orderId, productId, quantity, configuration? }` | Add product to cart | | `updateProductItem` | `{ orderPositionId, quantity, configuration }` | Update line item | | `delete` | `orderPositionId` | Remove line item | | `removePositions` | `{ orderId }` | Clear all positions | **`modules.orders.payments`** β€” Order payments: | Method | Arguments | Description | |--------|-----------|-------------| | `findOrderPayment` | `{ orderPaymentId }` | Get payment record | | `updateStatus` | `orderPaymentId, { status, transactionId?, info? }` | Update payment status | | `markAsPaid` | `orderPaymentId, info?` | Mark as paid | **`modules.orders.deliveries`** β€” Order deliveries: | Method | Arguments | Description | |--------|-----------|-------------| | `findOrderDelivery` | `{ orderDeliveryId }` | Get delivery record | | `updateStatus` | `orderDeliveryId, { status, info? }` | Update delivery status | **`modules.orders.discounts`** β€” Order discounts: | Method | Arguments | Description | |--------|-----------|-------------| | `findOrderDiscount` | `{ orderDiscountId }` | Get discount record | | `create` | `doc` | Create discount | | `delete` | `orderDiscountId` | Remove discount | ### Statistics | Method | Arguments | Description | |--------|-----------|-------------| | `statistics.countByDateField` | `dateField, dateRange?, options?` | Count orders by date | | `statistics.aggregateByDateField` | `dateField, dateRange?, options?` | Aggregate stats by date | | `statistics.getTopCustomers` | `orderIds, options?` | Get top spending customers | ## Events | Event | Payload | Description | |-------|---------|-------------| | `ORDER_CREATE` | `{ order }` | Emitted when an order is created | | `ORDER_UPDATE` | `{ order, field }` | Emitted when an order is updated | | `ORDER_REMOVE` | `{ orderId }` | Emitted when an order is removed | | `ORDER_CHECKOUT` | `{ order, oldStatus }` | Emitted when checkout is initiated | | `ORDER_CONFIRMED` | `{ order, oldStatus }` | Emitted when an order is confirmed | | `ORDER_FULFILLED` | `{ order, oldStatus }` | Emitted when an order is fulfilled | | `ORDER_REJECTED` | `{ order, oldStatus }` | Emitted when an order is rejected | | `ORDER_ADD_PRODUCT` | `{ orderPosition }` | Emitted when a product is added to cart | | `ORDER_UPDATE_CART_ITEM` | `{ orderPosition }` | Emitted when a cart item is updated | | `ORDER_REMOVE_CART_ITEM` | `{ orderPosition }` | Emitted when a cart item is removed | | `ORDER_EMPTY_CART` | `{ orderId, count }` | Emitted when cart is emptied | | `ORDER_SET_DELIVERY_PROVIDER` | `{ order }` | Emitted when delivery provider is set | | `ORDER_SET_PAYMENT_PROVIDER` | `{ order }` | Emitted when payment provider is set | | `ORDER_UPDATE_DELIVERY` | `{ orderDelivery }` | Emitted when delivery is updated | | `ORDER_DELIVER` | `{ orderDelivery }` | Emitted when order is delivered | | `ORDER_CREATE_DISCOUNT` | `{ discount }` | Emitted when a discount is created | | `ORDER_UPDATE_DISCOUNT` | `{ discount }` | Emitted when a discount is updated | | `ORDER_REMOVE_DISCOUNT` | `{ discount }` | Emitted when a discount is removed | ## More Information For API usage and detailed documentation, see the [core-orders package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-orders). --- ## Payment Module The payment module manages payment provider selection and configuration. ## Configuration Options ```typescript export type FilterProvider = ( params: { providers: PaymentProvider[]; order: Order; }, unchainedAPI: UnchainedAPI, ) => Promise; export type DetermineDefaultProvider = ( params: { providers: PaymentProvider[]; paymentCredentials: PaymentCredentials[]; order: Order; }, unchainedAPI: UnchainedAPI, ) => Promise; export interface PaymentSettingsOptions { filterSupportedProviders?: FilterProviders; determineDefaultProvider?: DetermineDefaultProvider; } ``` ### Custom Filtering ```typescript const options = { modules: { payment: { filterSupportedProviders: ({ order, providers }) => { return providers .toSorted((left, right) => { return new Date(left.created).getTime() - new Date(right.created).getTime(); }) .filter((provider) => { return process.env.NODE_ENV === 'production' ? provider._id !== 'free' : true; }); }, }, }, }; ``` By default we return all providers based on the creation date and don't filter any. You can't return inactive payment providers in general. ### Default Provider Selection for New Orders ```typescript const options = { modules: { payment: { determineDefaultProvider: ({ order, providers }) => { return providers?.find(({ _id }) => _id === 'this-id-always-default'); }, }, }, }; ``` By default the default provider is defined as first in list of providers matching credentials, if no credentials: first in list of providers (transformed by `filterSupportedProviders` first). ## Events | Event | Payload | Description | |-------|---------|-------------| | `PAYMENT_PROVIDER_CREATE` | `{ paymentProvider }` | Emitted when a payment provider is created | | `PAYMENT_PROVIDER_UPDATE` | `{ paymentProvider }` | Emitted when a payment provider is updated | | `PAYMENT_PROVIDER_REMOVE` | `{ paymentProvider }` | Emitted when a payment provider is removed | ## More Information For API usage and detailed documentation, see the [core-payment package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-payment). --- ## Products Module The products module manages the product catalog including simple products, bundles, configurable products, and subscription plans. ## Configuration Options ```typescript export interface ProductsSettingsOptions { slugify: (title: string) => string; } ``` ### Default Slugifier - [slugify](https://github.com/unchainedshop/unchained/blob/master/packages/utils/src/slugify.ts) ### Custom Slugify ```typescript const options = { modules: { products: { slugify, }, }, }; ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `PRODUCT_CREATE` | `{ product }` | Emitted when a product is created | | `PRODUCT_UPDATE` | `{ product }` | Emitted when a product is updated | | `PRODUCT_REMOVE` | `{ productId }` | Emitted when a product is removed | | `PRODUCT_PUBLISH` | `{ product }` | Emitted when a product is published | | `PRODUCT_UNPUBLISH` | `{ product }` | Emitted when a product is unpublished | | `PRODUCT_UPDATE_TEXT` | `{ productId, locale }` | Emitted when product text is updated | | `PRODUCT_ADD_MEDIA` | `{ productMedia }` | Emitted when media is added | | `PRODUCT_REMOVE_MEDIA` | `{ productMediaId }` | Emitted when media is removed | | `PRODUCT_REORDER_MEDIA` | `{ productMedias }` | Emitted when media is reordered | | `PRODUCT_UPDATE_MEDIA_TEXT` | `{ productMediaId }` | Emitted when media text is updated | | `PRODUCT_CREATE_VARIATION` | `{ productVariation }` | Emitted when a variation is created | | `PRODUCT_REMOVE_VARIATION` | `{ productVariationId }` | Emitted when a variation is removed | | `PRODUCT_UPDATE_VARIATION_TEXT` | `{ productVariationId }` | Emitted when variation text is updated | | `PRODUCT_VARIATION_OPTION_CREATE` | `{ productVariation, value }` | Emitted when a variation option is added | | `PRODUCT_REMOVE_VARIATION_OPTION` | `{ productVariationId, value }` | Emitted when a variation option is removed | | `PRODUCT_REVIEW_CREATE` | `{ productReview }` | Emitted when a review is created | | `PRODUCT_UPDATE_REVIEW` | `{ productReview }` | Emitted when a review is updated | | `PRODUCT_REMOVE_REVIEW` | `{ productReviewId }` | Emitted when a review is removed | | `PRODUCT_REVIEW_ADD_VOTE` | `{ productReviewId, type }` | Emitted when a vote is added to a review | | `PRODUCT_REMOVE_REVIEW_VOTE` | `{ productReviewId, type }` | Emitted when a vote is removed from a review | ## More Information For API usage and detailed documentation, see the [core-products package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-products). --- ## Quotations Module The quotations module manages quote requests and proposal workflows. ## Configuration Options ```typescript export interface QuotationsSettingsOptions { quotationNumberHashFn?: (quotation: Quotation, index: number) => string; } ``` ### Quotation Number Creation The `quotationNumberHashFn` is used to generate human-readable codes that can be easily spelled out to support staff. The default is a hashids based function that generates an alphanumeric uppercase string with length 6 without the hard to distinguish 0IOl etc. If the number has already been taken, the function gets iteratively called with an increasing `index`. [Default Random Hash Generator](https://github.com/unchainedshop/unchained/blob/master/packages/utils/src/generate-random-hash.ts) ### Example Custom Configuration ```typescript const options = { modules: { quotations: { quotationNumberHashFn: (quotation, index) => quotation.sequence + 300000 + index, }, }, }; ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `QUOTATION_REQUEST_CREATE` | `{ quotation }` | Emitted when a quotation request is created | | `QUOTATION_UPDATE` | `{ quotation, field }` | Emitted when a quotation is updated | ## More Information For API usage and detailed documentation, see the [core-quotations package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-quotations). --- ## Users Module The users module handles user authentication, registration, profiles, and account management. ## Configuration Options ```typescript export interface UserSettingsOptions { mergeUserCartsOnLogin?: boolean; autoMessagingAfterUserCreation?: boolean; earliestValidTokenDate?: ( type: UserAccountAction.VERIFY_EMAIL | UserAccountAction.RESET_PASSWORD, ) => Date; validateEmail?: (email: string) => Promise; validateUsername?: (username: string) => Promise; validateNewUser?: (user: UserRegistrationData) => Promise; validatePassword?: (password: string) => Promise; } ``` ### User Cart Merging Assuming somebody starts his journey in your web shop with a guest user and you want to provide a late "login", enabling `mergeUserCartsOnLogin` will migrate the guest cart to the logged in user's cart. (default: enabled) ### Auto Messaging After User Creation If Auto Messaging is turned on and E-Mail is provided during registration, Unchained will (default: disabled): 1. Send an E-Mail Verification Link to users that registered with a password 2. Send Set-Password Link to users that registered without a password The token in the link allows auto sign-in once the password is set or the E-mail address is verified. ### Token Invalidation When sending reset-password or e-mail verification links, tokens are generated. To control how long those tokens are valid, you can customize `earliestValidTokenDate`. For example if you want the tokens to be valid for 30 days (default: 1 hour): ```typescript const options = { modules: { users: { earliestValidTokenDate: () => { return new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30); }, }, }, }; ``` Changing this will affect newly created tokens and older tokens so you can safely play with it, you could even set it to 10 years and later reduce. ### Validate User Data on Registration Unchained provides different hooks to validate user registration data, here is an example to restrict registration to an e-mail address suffix: ```typescript const options = { modules: { users: { validateEmail: (emailAddress) => { return emailAddress.endsWith("@unchained.shop"); }, }, }, }; ``` By default, Unchained does the following: 1. Allow every password as long as it's minimum 8 chars 2. Allow every username as long as it's minimum 3 chars and does not exist in the db already 3. Allow every e-mail that has an `@` and does not exist in the db already 4. Sanitize the user data in `validateNewUser` to: lowercase e-mail, lowercase username. :::warning Security Advice: If you use a 3rd party identity provider for example Zitadel, Microsoft Entra or Keycloak, you should probably disable registration by throwing an error in `validateNewUser` and disable changing username/e-mail on unchained users by returning false in the validate* functions. ::: ## Events | Event | Payload | Description | |-------|---------|-------------| | `USER_CREATE` | `{ user }` | Emitted when a user is created | | `USER_UPDATE` | `{ userId }` | Emitted when a user is updated | | `USER_REMOVE` | `{ userId }` | Emitted when a user is removed | | `USER_ACCOUNT_ACTION` | `{ action, userId }` | Emitted for account actions (verify email, reset password) | | `USER_ADD_ROLES` | `{ userId, roles }` | Emitted when roles are added to a user | | `USER_UPDATE_USERNAME` | `{ userId }` | Emitted when username is updated | | `USER_UPDATE_PASSWORD` | `{ userId }` | Emitted when password is updated | | `USER_UPDATE_AVATAR` | `{ userId }` | Emitted when avatar is updated | | `USER_UPDATE_GUEST` | `{ userId }` | Emitted when guest status changes | | `USER_UPDATE_HEARTBEAT` | `{ userId }` | Emitted on user heartbeat | | `USER_UPDATE_PROFILE` | `{ userId }` | Emitted when profile is updated | | `USER_UPDATE_BILLING_ADDRESS` | `{ userId }` | Emitted when billing address is updated | | `USER_UPDATE_LAST_CONTACT` | `{ userId }` | Emitted when last contact is updated | | `USER_UPDATE_ROLE` | `{ userId }` | Emitted when role is updated | | `USER_UPDATE_TAGS` | `{ userId }` | Emitted when tags are updated | ## More Information For API usage and detailed documentation, see the [core-users package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-users). --- ## Warehousing Module The warehousing module manages inventory, stock levels, warehousing providers, and tokenized product handling. ## Configuration Options The warehousing module has no configuration options. ## Module API Access via `modules.warehousing` in the Unchained API context. ### Provider Queries | Method | Arguments | Description | |--------|-----------|-------------| | `findProvider` | `{ warehousingProviderId }` | Get a specific provider | | `findProviders` | `query?, options?` | List warehousing providers | | `allProviders` | β€” | Get all active providers (cached) | | `count` | `query?` | Count providers | | `providerExists` | `{ warehousingProviderId }` | Check if provider exists | ### Provider Mutations | Method | Arguments | Description | |--------|-----------|-------------| | `create` | `doc` | Create a warehousing provider | | `update` | `warehousingProviderId, doc` | Update provider | | `delete` | `providerId` | Delete provider | ### Token Operations For tokenized products (NFTs, digital assets): | Method | Arguments | Description | |--------|-----------|-------------| | `findToken` | `{ tokenId }, options?` | Get a specific token | | `findTokens` | `selector, options?` | List tokens | | `tokensCount` | `selector?` | Count tokens | | `findTokensForUser` | `{ userId, limit?, offset? }` | Get user's tokens | | `createTokens` | `tokens` | Batch create tokens | | `updateTokenOwnership` | `tokenId, { userId, originalProductId? }` | Transfer token ownership | | `invalidateToken` | `tokenId` | Revoke a token | | `buildAccessKeyForToken` | `tokenId` | Generate an access key | ### Usage ```typescript // Create a warehousing provider await modules.warehousing.create({ adapterKey: 'shop.unchained.warehousing.store', type: 'PHYSICAL', configuration: [], }); // Find tokens for a user const tokens = await modules.warehousing.findTokensForUser({ userId: 'user-123', limit: 10, }); // Transfer token ownership await modules.warehousing.updateTokenOwnership('token-id', { userId: 'new-owner-id', }); ``` ## Events | Event | Payload | Description | |-------|---------|-------------| | `WAREHOUSING_PROVIDER_CREATE` | `{ warehousingProvider }` | Emitted when a warehousing provider is created | | `WAREHOUSING_PROVIDER_UPDATE` | `{ warehousingProvider }` | Emitted when a warehousing provider is updated | | `WAREHOUSING_PROVIDER_REMOVE` | `{ warehousingProvider }` | Emitted when a warehousing provider is removed | ## Related - [Warehousing Plugins](../../plugins/warehousing/index.md) - Available warehousing adapters - [ETH Minter](../../plugins/warehousing/warehousing-eth-minter.md) - NFT minting plugin --- ## Worker Module The worker module manages background job processing and work queue security. ## Configuration Options ```typescript export interface WorkerSettingsOptions { blacklistedVariables?: string[]; } ``` ### Blacklisting Variables Security Feature. You can provide a custom list of blacklisted variables, keys which are part of the blacklist will be obfuscated with `*****` in Work Queue APIs and when publishing Events. Example custom configuration: ```typescript const options = { modules: { worker: { blacklistedVariables: ['secret-key'], }, }, }; ``` By default, those variables are filtered: [buildObfuscatedFieldsFilter](https://github.com/unchainedshop/unchained/blob/master/packages/utils/src/build-obfuscated-fields-filter.ts) ## Events The worker module does not emit events directly. Work items are processed by registered worker plugins which may emit their own events. ## More Information For API usage and detailed documentation, see the [core-worker package on GitHub](https://github.com/unchainedshop/unchained/tree/master/packages/core-worker). --- ## Plugin Presets Unchained Engine provides several pre-configured plugin bundles (presets) that make it easy to get started with different configurations. These presets automatically import and configure commonly used plugins for specific use cases. ## Available Presets ### Base Preset (`@unchainedshop/plugins/presets/base`) boot.ts ```ts const platform = await startPlatform({ modules, }); connect(app, platform) // Either: // a) Load GridFS REST endpoints for Express.js: // a) Load GridFS REST endpoints for Fastify: connectPlugins(app); ``` The base preset includes essential plugins for a minimal e-commerce setup: **Payment Providers:** - Invoice payment **Delivery Methods:** - Post delivery **Warehousing:** - Store warehousing **Pricing:** - Free payment pricing - Free delivery pricing - Order items pricing - Order discount pricing - Order delivery pricing - Order payment pricing - Product catalog pricing - Product discount pricing **Quotations:** - Manual quotations **Enrollments:** - Licensed enrollments **Event System:** - Node.js Event Emitter **Workers:** - Bulk import - Zombie killer (cleanup) - Message handling - External service integration - HTTP request handling - Heartbeat monitoring - Email notifications - Error notifications **File Storage:** - GridFS (MongoDB) file storage with modules ### All Preset (`@unchainedshop/plugins/presets/all`) boot.ts ```ts const platform = await startPlatform({ modules, }); connect(app, platform) // Either: // a) Load all custom API handlers for Express.js: // b) Load all custom API handlers for Fastify: connectPlugins(app); ``` The all preset extends the base preset with additional payment providers, delivery methods, and features: **Includes everything from Base Preset plus:** **Additional Payment Providers:** - Invoice prepaid - PayPal Checkout - Apple In-App Purchase - Saferpay - Stripe - PostFinance Checkout - Datatrans v2 - PayRexx **Additional Delivery Methods:** - Send message delivery - Store pickup **Search & Filtering:** - Strict equal filtering - Local search **Additional Workers:** - Twilio SMS - Bulkgate SMS - BudgetSMS SMS - Push notifications - Enrollment order generator **Country-Specific Extensions:** - Switzerland (CH) specific plugins **Crypto Features:** - All crypto-related plugins from crypto preset ### Crypto Preset (`@unchainedshop/plugins/presets/crypto`) boot.ts ```ts const platform = await startPlatform({ modules: { ...baseModules, ...cryptoModules, }, }); connect(app, platform) // a) Load Crypto API handlers for Express.js: // b) Load Crypto API handlers for Fastify: // Make sure you load the base plugins too as those are not part of the crypto preset! connectCryptoPlugins(app); connectPlugins(app) ``` Specialized preset for cryptocurrency and blockchain functionality: **Payment Providers:** - Cryptopay (self-hosted crypto payments) **Warehousing:** - Ethereum token minting **Pricing:** - Product price rate conversion **Workers:** - ECB currency rate updates - Coinbase currency rate updates - Token export functionality ### Country-Specific Presets #### Switzerland (`@unchainedshop/plugins/presets/countries/ch`) boot.ts ```ts // The CH preset exists only of plugins and doesn't expose any custom modules! const platform = await startPlatform({ modules, }); connect(app, platform) // a) Load Base API handlers for Express.js: // b) Load Base API handlers for Fastify: // Make sure you load the base plugins too as those are not part of the ch preset! connectPlugins(app) ``` **Delivery:** - Pick-Mup delivery service **Pricing:** - Swiss tax calculation for products - Swiss tax calculation for delivery ## Plugins Not Included in Presets Some plugins are **not** automatically loaded by any preset and must be imported explicitly: **Payment Providers:** - [Braintree](../plugins/payment/braintree.md) - PayPal-owned payment processor To use these plugins, import them directly in your project in addition to your chosen preset. ## Best Practices 1. **Start with Base**: Begin with the base preset and add plugins as needed 2. **Use All for Development**: The all preset is great for development and testing all features 3. **Production Optimization**: In production, use only the plugins you need for better performance 4. **Framework Consistency**: Always use the matching framework connector (Fastify or Express) 5. **Environment Configuration**: Configure plugins through environment variables for flexibility --- ## Post Delivery # Post Delivery Adapter The Post adapter provides standard postal/courier delivery functionality. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Configuration Create a delivery provider using this adapter: ```graphql mutation CreatePostDelivery { createDeliveryProvider(deliveryProvider: { type: SHIPPING adapterKey: "shop.unchained.delivery.post" }) { _id } } ``` ## Features - Standard shipping delivery type - Configurable estimated delivery time - Auto-release support - No external API dependencies ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.delivery.post` | | Type | `SHIPPING` | | Auto-release | Configurable | | Source | [delivery/post.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/delivery/post.ts) | ## Behavior ### `isActive()` Always returns `true` - no configuration required. ### `isAutoReleaseAllowed()` Returns `true` by default, allowing orders to proceed automatically after payment. ### `send()` Returns success without external API calls. For production integrations with actual carriers, create a custom adapter. ### `estimatedDeliveryThroughput()` Returns a default delivery estimate. Override in configuration or extend the adapter for custom calculations. ## Extending for Real Carriers For production use, extend or replace this adapter with carrier-specific integrations: ```typescript const SwissPostAdapter = { key: 'ch.post.delivery', label: 'Swiss Post', version: '1.0.0', typeSupported: (type) => type === 'SHIPPING', actions(config, context) { return { configurationError() { return null; }, isActive() { return true; }, isAutoReleaseAllowed() { return true; }, async send() { const { order } = context; // Call Swiss Post API const response = await swissPostApi.createShipment({ recipient: order.delivery.address, weight: calculateWeight(order.items), }); return { trackingNumber: response.trackingNumber, trackingUrl: `https://www.post.ch/track?id=${response.trackingNumber}`, }; }, estimatedDeliveryThroughput(warehousingTime) { // Swiss Post typically delivers in 1-2 days return warehousingTime + (2 * 24 * 60 * 60 * 1000); }, async pickUpLocations() { return []; }, async pickUpLocationById() { return null; }, }; }, }; DeliveryDirector.registerAdapter(SwissPostAdapter); ``` ## Delivery Pricing Combine with delivery pricing adapters: ```typescript ``` Set prices via configuration or custom pricing adapter. ## Related - [Plugins Overview](./) - All available plugins - [Stores Delivery](./delivery-stores.md) - Pickup delivery - [Delivery Pricing](../../extend/pricing/delivery-pricing.md) - Pricing configuration - [Custom Delivery Plugins](../../extend/order-fulfilment/fulfilment-plugins/delivery.md) - Write your own --- ## Send Message Delivery # Send Message Delivery Adapter The Send Message adapter provides digital delivery functionality by sending order details via the messaging system. ## Installation ```typescript ``` ## Configuration Create a delivery provider for digital products: ```graphql mutation CreateSendMessageDelivery { createDeliveryProvider(deliveryProvider: { type: SHIPPING adapterKey: "shop.unchained.delivery.send-message" }) { _id } } ``` Configure the provider after creation using the Admin UI or by updating the provider's configuration: ```json [ { "key": "from", "value": "shop@example.com" }, { "key": "to", "value": "" }, { "key": "cc", "value": "fulfillment@example.com" } ] ``` ## Features - Digital product delivery - Email-based fulfillment - Configurable recipients - Worker-based message queue - Template-based messaging ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.delivery.send-message` | | Type | `SHIPPING` | | Auto-release | Default (configurable) | | Source | [delivery/send-message.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/delivery/send-message.ts) | ## Configuration Options | Key | Description | Default | |-----|-------------|---------| | `from` | Sender email address | Empty | | `to` | Recipient email (overrides order email) | Empty | | `cc` | CC email address | Empty | ## Behavior ### `isActive()` Always returns `true`. ### `send()` Creates a worker job with the `MESSAGE` type using the `DELIVERY` template: ```typescript await modules.worker.addWork({ type: 'MESSAGE', retries: 0, input: { template: 'DELIVERY', orderId: order._id, config, }, }); ``` ## Use Cases ### Digital Products Ideal for: - Software licenses - Download links - Access codes - E-books and digital media - Event tickets - Gift cards ### Notification Delivery Send order details to: - Customer email - Fulfillment center - Third-party systems ## Message Template Configure the `DELIVERY` template in your messaging setup: ```typescript const DeliveryTemplate = { key: 'DELIVERY', label: 'Delivery Notification', version: '1.0.0', actions: (config, context) => ({ async send() { const { orderId, config: deliveryConfig } = context.work.input; const { modules } = context; const order = await modules.orders.findOrder({ orderId }); const items = await modules.orders.positions.findOrderPositions({ orderId }); // Generate download links, license keys, etc. const deliveryContent = await generateDeliveryContent(items); return { to: deliveryConfig.to || order.contact.emailAddress, from: deliveryConfig.from, cc: deliveryConfig.cc, subject: `Your order ${order.orderNumber} - Download Ready`, html: renderDeliveryEmail(order, deliveryContent), }; }, }), }; MessagingDirector.registerAdapter(DeliveryTemplate); ``` ## Custom Digital Delivery Adapter For more complex digital delivery scenarios: ```typescript const DigitalDeliveryAdapter: IDeliveryAdapter = { key: 'my-shop.digital-delivery', label: 'Digital Product Delivery', version: '1.0.0', typeSupported: (type) => type === 'SHIPPING', actions(config, context) { return { configurationError() { return null; }, isActive() { return true; }, isAutoReleaseAllowed() { return true; }, async send() { const { order, modules } = context; const positions = await modules.orders.positions.findOrderPositions({ orderId: order._id, }); const deliveryItems = []; for (const position of positions) { const product = await modules.products.findProduct({ productId: position.productId, }); if (product.type === 'SIMPLE') { // Generate license key const licenseKey = await generateLicenseKey(product, order); deliveryItems.push({ product: product.texts?.title, licenseKey, }); } if (product.meta?.downloadUrl) { // Generate signed download URL const downloadUrl = await generateSignedUrl( product.meta.downloadUrl, { expiresIn: '7d' } ); deliveryItems.push({ product: product.texts?.title, downloadUrl, }); } } // Queue delivery email await modules.worker.addWork({ type: 'MESSAGE', input: { template: 'DIGITAL_DELIVERY', orderId: order._id, deliveryItems, }, }); return { status: 'DELIVERED', deliveryItems, }; }, estimatedDeliveryThroughput() { // Instant delivery return 0; }, async pickUpLocations() { return []; }, async pickUpLocationById() { return null; }, }; }, }; DeliveryDirector.registerAdapter(DigitalDeliveryAdapter); ``` ## Combining with Physical Delivery For products with both physical and digital components: ```typescript async send() { const { order, modules } = context; const positions = await modules.orders.positions.findOrderPositions({ orderId: order._id, }); const digitalItems = positions.filter(p => p.product?.meta?.isDigital); const physicalItems = positions.filter(p => !p.product?.meta?.isDigital); // Handle digital items immediately if (digitalItems.length > 0) { await modules.worker.addWork({ type: 'MESSAGE', input: { template: 'DIGITAL_DELIVERY', orderId: order._id, items: digitalItems, }, }); } // Physical items handled by warehouse return { digitalDelivered: digitalItems.length, physicalPending: physicalItems.length, }; } ``` ## Related - [Plugins Overview](./) - All available plugins - [Post Delivery](./delivery-post.md) - Physical shipping - [Worker](../../extend/worker.md) - Background job processing - [Custom Delivery Plugins](../../extend/order-fulfilment/fulfilment-plugins/delivery.md) - Write your own --- ## Stores Delivery # Stores Delivery Adapter The Stores adapter provides pickup location functionality for in-store or warehouse pickup. ## Installation ```typescript ``` ## Configuration Create a delivery provider with pickup locations: ```graphql mutation CreateStoresDelivery { createDeliveryProvider(deliveryProvider: { type: PICKUP adapterKey: "shop.unchained.stores" }) { _id } } ``` Configure the stores after creation via the Admin UI or update the provider's configuration with a JSON array of stores. ## Features - Store/warehouse pickup support - Multiple pickup location management - JSON-based store configuration - No external API dependencies ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.stores` | | Type | `PICKUP` | | Auto-release | `false` | | Source | [delivery/stores.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/delivery/stores.ts) | ## Configuration Options ### `stores` JSON array of pickup locations. Each location should have: ```json [ { "_id": "store-1", "name": "Main Store", "address": "123 Main Street, Zurich", "city": "Zurich", "postalCode": "8001", "countryCode": "CH", "coordinates": { "lat": 47.3769, "lng": 8.5417 }, "openingHours": "Mon-Fri 9-18, Sat 9-16" }, { "_id": "store-2", "name": "Airport Shop", "address": "Zurich Airport, Terminal 2" } ] ``` ## Behavior ### `isActive()` Always returns `true` - no configuration required. ### `isAutoReleaseAllowed()` Returns `false` - pickup orders require manual confirmation. ### `pickUpLocations()` Returns all configured store locations. ### `pickUpLocationById(id)` Returns a specific store location by ID. ## Usage in Checkout ```graphql query GetPickupLocations($providerId: ID!) { deliveryProvider(deliveryProviderId: $providerId) { ... on DeliveryProviderPickUp { simulatedPrice { amount currencyCode } pickUpLocations { _id name address { addressLine city } geoPoint { latitude longitude } } } } } ``` Select pickup location for order: ```graphql mutation SetPickupLocation($deliveryProviderId: ID!, $locationId: ID!) { updateCartDeliveryPickUp( deliveryProviderId: $deliveryProviderId orderPickUpLocationId: $locationId ) { _id delivery { ... on OrderDeliveryPickUp { activePickUpLocation { _id name } } } } } ``` ## Extending for Dynamic Stores For stores managed in a database or external system: ```typescript const DynamicStoresAdapter: IDeliveryAdapter = { key: 'my-shop.dynamic-stores', label: 'Dynamic Store Locations', version: '1.0.0', typeSupported: (type) => type === 'PICKUP', actions(config, context) { const { modules } = context; return { configurationError() { return null; }, isActive() { return true; }, isAutoReleaseAllowed() { return false; }, async pickUpLocations() { // Fetch from database or external API const stores = await modules.warehousing.findWarehouses({ type: 'STORE', isActive: true, }); return stores.map(store => ({ _id: store._id, name: store.name, address: store.address, geoPoint: store.coordinates ? { latitude: store.coordinates.lat, longitude: store.coordinates.lng, } : null, })); }, async pickUpLocationById(locationId) { const store = await modules.warehousing.findWarehouse({ _id: locationId }); if (!store) return null; return { _id: store._id, name: store.name, address: store.address, }; }, async send() { // Notify store about pickup order const { order } = context; await notifyStore(order); return { status: 'READY_FOR_PICKUP' }; }, estimatedDeliveryThroughput(warehousingTime) { // Pickup ready same day return warehousingTime; }, }; }, }; DeliveryDirector.registerAdapter(DynamicStoresAdapter); ``` ## Store Locator Integration Combine with geolocation for nearest store finder: ```typescript async pickUpLocations(searchParams) { const stores = await getAllStores(); if (searchParams?.coordinates) { // Sort by distance return stores .map(store => ({ ...store, distance: calculateDistance( searchParams.coordinates, store.coordinates ), })) .sort((a, b) => a.distance - b.distance); } return stores; } ``` ## Related - [Plugins Overview](./) - All available plugins - [Post Delivery](./delivery-post.md) - Shipping delivery - [Delivery Pricing](../../extend/pricing/delivery-pricing.md) - Pricing configuration - [Custom Delivery Plugins](../../extend/order-fulfilment/fulfilment-plugins/delivery.md) - Write your own --- ## Delivery Plugins Delivery plugins handle shipping and fulfillment methods. | Adapter Key | Description | Use Case | Tracking | |-------------|-------------|----------|----------| | [`shop.unchained.post`](./delivery-post.md) | Generic postal delivery | Physical goods shipped via postal service | Manual | | [`shop.unchained.delivery.send-message`](./delivery-send-message.md) | Message-based delivery (digital) | Digital goods, license keys, download links | Automatic | | [`shop.unchained.stores`](./delivery-stores.md) | In-store pickup | Click-and-collect, local pickup | Manual | ## Creating Custom Delivery Plugins See [Custom Delivery Plugins](../../extend/order-fulfilment/fulfilment-plugins/delivery.md) for creating your own delivery adapters. --- ## Licensed Enrollments A subscription adapter for licensed products that grants access based on active periods. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Features - **Period-Based Access**: Access is granted when current date falls within an active period - **Automatic Order Generation**: Orders are created at the beginning of each period - **Simple Licensing Model**: One product per enrollment period - **No Overdue Handling**: Designed for prepaid subscriptions ## How It Works 1. Customer purchases a `PLAN_PRODUCT` with `usageCalculationType: LICENSED` 2. Enrollment is created with defined periods 3. At period start, an order is automatically generated 4. Access is valid while current date is within an active period ## Product Configuration Create a plan product for licensed subscriptions: ```graphql mutation CreateSubscriptionProduct { createProduct(product: { type: PLAN_PRODUCT }) { _id } } mutation UpdatePlanData { updateProductPlan( productId: "product-id" plan: { usageCalculationType: LICENSED billingInterval: MONTHS billingIntervalCount: 1 trialIntervalCount: 0 } ) { _id ... on PlanProduct { plan { usageCalculationType billingInterval } } } } ``` ## Activation Logic The adapter activates only for products with `usageCalculationType: LICENSED`: ```typescript isActivatedFor: (productPlan) => { return productPlan?.usageCalculationType === 'LICENSED'; } ``` ## Validity Check Access is granted when the current date falls within any enrollment period: ```typescript isValidForActivation: async () => { const periods = enrollment?.periods || []; const now = new Date(); return periods.some(period => { const start = new Date(period.start); const end = new Date(period.end); return start <= now && end >= now; }); } ``` ## Order Generation Orders are generated at the beginning of each period: ```typescript configurationForOrder: async ({ period }) => { const beginningOfPeriod = period.start.getTime() <= Date.now(); if (beginningOfPeriod) { return { period, orderContext: {}, orderPositionTemplates: [{ quantity: 1, productId: enrollment.productId, originalProductId: enrollment.productId, }], }; } return null; } ``` ## Usage ### Create Enrollment ```graphql mutation CreateEnrollment { createEnrollment( plan: { productId: "plan-product-id" quantity: 1 } ) { _id status } } ``` ### Query Enrollments ```graphql query MyEnrollments { me { enrollments { _id status plan { product { texts { title } } } periods { start end isTrial order { _id orderNumber } } } } } ``` ### Check Access ```graphql query CheckAccess { enrollment(enrollmentId: "enrollment-id") { _id status isExpired } } ``` ### Terminate Enrollment ```graphql mutation TerminateSubscription { terminateEnrollment(enrollmentId: "enrollment-id") { _id status } } ``` ## Automatic Order Generation Use the [Enrollment Order Generator Worker](../workers/worker-enrollment-order-generator.md) to automatically generate orders: ```typescript // Run daily at midnight enrollmentsSettings.autoSchedulingSchedule = schedule.parse.cron('0 0 * * *'); configureGenerateOrderAutoscheduling(); ``` ## Extending the Adapter For custom subscription logic: ```typescript const CustomEnrollmentAdapter: IEnrollmentAdapter = { ...EnrollmentAdapter, key: 'my-shop.enrollments.custom', version: '1.0.0', label: 'Custom Subscription', isActivatedFor: (productPlan) => { return productPlan?.usageCalculationType === 'METERED'; }, actions: (params) => { const { enrollment, modules } = params; return { ...EnrollmentAdapter.actions(params), isValidForActivation: async () => { // Custom validation logic const periods = enrollment?.periods || []; const hasActivePeriod = periods.some(p => { const now = Date.now(); return new Date(p.start).getTime() <= now && new Date(p.end).getTime() >= now; }); // Also check payment status const latestOrder = await modules.orders.findOrder({ enrollmentId: enrollment._id, sort: { created: -1 }, }); return hasActivePeriod && latestOrder?.status === 'CONFIRMED'; }, isOverdue: async () => { // Check if payment is overdue const gracePeriodDays = 7; const periods = enrollment?.periods || []; const currentPeriod = periods.find(p => { const now = Date.now(); return new Date(p.start).getTime() <= now; }); if (!currentPeriod?.orderId) return false; const order = await modules.orders.findOrder({ orderId: currentPeriod.orderId, }); if (order?.status !== 'PENDING') return false; const dueDate = new Date(currentPeriod.start); dueDate.setDate(dueDate.getDate() + gracePeriodDays); return Date.now() > dueDate.getTime(); }, configurationForOrder: async ({ period }) => { // Custom order generation with usage-based pricing const usage = await calculateUsage(enrollment._id, period); return { period, orderContext: { usage }, orderPositionTemplates: [{ quantity: usage.units, productId: enrollment.productId, originalProductId: enrollment.productId, configuration: [ { key: 'usageUnits', value: String(usage.units) }, ], }], }; }, }; }, }; EnrollmentDirector.registerAdapter(CustomEnrollmentAdapter); ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.enrollments.licensed` | | Version | `1.0.0` | | Source | [enrollments/licensed.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/enrollments/licensed.ts) | ## Related - [Plugins Overview](./) - All available plugins - [Enrollment Order Generator](../workers/worker-enrollment-order-generator.md) - Auto-generate orders --- ## Enrollment Plugins Enrollment plugins handle subscription-based products and recurring orders. ## Available Plugins | Adapter Key | Description | Base Preset | |-------------|-------------|-------------| | [`shop.unchained.enrollments.licensed`](./enrollment-licensed.md) | Licensed subscription with period-based access | Yes | ## How Enrollments Work Enrollments in Unchained manage subscription products: 1. Customer purchases a subscription product (PLAN_PRODUCT) 2. An enrollment is created linking the customer to the product 3. The enrollment adapter determines billing periods 4. Orders are automatically generated for each period 5. Access is granted based on active periods ## Enrollment Flow ```mermaid flowchart LR A[Customer Purchase] --> B[Enrollment Created] B --> C[Period Starts] C --> D[Order Generated] D --> E[Payment] E --> F[Access Granted] F --> G[Period Ends] G --> C ``` ## Key Concepts ### Enrollment Status | Status | Description | |--------|-------------| | `INITIAL` | Enrollment created but not yet active | | `ACTIVE` | Subscription is active | | `PAUSED` | Temporarily paused (can resume) | | `TERMINATED` | Permanently ended | ### Periods Each enrollment tracks periods which represent billing cycles: - `start` - Period start date - `end` - Period end date - `isTrial` - Whether this is a trial period - `orderId` - Associated order for this period ## Creating Custom Enrollment Plugins See [Custom Enrollment Plugins](../../extend/enrollment.md) for creating your own enrollment adapters. --- ## AWS EventBridge Enterprise event system using AWS EventBridge for cloud-native event routing. ## Installation ```typescript ``` Requires the AWS SDK as a peer dependency: ```bash npm install @aws-sdk/client-eventbridge ``` :::warning Explicit Configuration Required Unlike the Node.js event emitter (which is the default), this plugin requires explicit configuration. You must call `setEmitAdapter()` to activate EventBridge as your event system: ```typescript const adapter = await EventBridgeEventEmitter({ region: 'us-east-1', source: 'com.mycompany.unchained', busName: 'unchained-events', }); setEmitAdapter(adapter); ``` ::: ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `EVENT_BRIDGE_REGION` | - | AWS region (required) | | `EVENT_BRIDGE_SOURCE` | - | Event source identifier (required) | | `EVENT_BRIDGE_BUS_NAME` | - | EventBridge custom bus name (required) | | `AWS_ACCESS_KEY_ID` | - | AWS access key | | `AWS_SECRET_ACCESS_KEY` | - | AWS secret key | ## Features - **Cloud Native**: Fully managed AWS service - **Event Routing**: Advanced event routing and filtering - **Integrations**: Native integration with AWS services - **Scalability**: Automatic scaling and reliability - **Event Replay**: Built-in event replay capabilities - **Schema Registry**: Event schema management ## Use Cases - **AWS Environments**: Applications deployed on AWS - **Enterprise Integration**: Complex event routing requirements - **External Integrations**: Integration with AWS services and external systems - **Event Sourcing**: When you need event replay and auditing - **Compliance**: When you need audit trails and compliance features ## AWS Setup ### 1. Create EventBridge Custom Bus ```bash aws events create-event-bus --name "unchained-events" ``` ### 2. Create IAM Policy ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "events:PutEvents", "events:List*", "events:Describe*" ], "Resource": "*" } ] } ``` ### 3. Configure Environment ```bash EVENT_BRIDGE_REGION=us-east-1 EVENT_BRIDGE_SOURCE=com.mycompany.unchained EVENT_BRIDGE_BUS_NAME=unchained-events AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... ``` ## Usage ### Publishing Events ```typescript // Events are sent to EventBridge await emit('ORDER_CREATE', { orderId: '12345', userId: 'user123', total: 99.99 }); ``` ### Subscribing to Events EventBridge does not support direct subscription from the application. Use EventBridge rules to route events to: - Lambda functions - SQS queues - SNS topics - API Gateway endpoints - Other AWS services ## Performance - **Pros**: Fully managed, highly scalable, feature-rich - **Cons**: AWS dependency, higher cost, potential latency ## When to Use Use AWS EventBridge for: - AWS-based deployments - Complex event routing needs - Integration with AWS services - Enterprise compliance requirements - Event sourcing and replay needs ## Adapter Details | Property | Value | |----------|-------| | Source | [events/aws-eventbridge.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/aws-eventbridge.ts) | ## Related - [Node.js Events](./events-node.md) - In-memory events - [Redis Events](./events-redis.md) - Distributed events with Redis - [Plugins Overview](./) - All available plugins --- ## Node.js Event Emitter The default in-memory event system using Node.js built-in EventEmitter. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` This plugin automatically calls `setEmitAdapter()` when imported, making it the active event system immediately. ## Features - **In-Memory**: Events are handled within the same process - **High Performance**: Fast event handling with no network overhead - **Simple Setup**: No external dependencies required - **Development Friendly**: Perfect for development and testing - **Synchronous**: Events are processed synchronously within the process ## Use Cases - **Single Instance Deployments**: Applications running on a single server - **Development Environment**: Local development and testing - **Simple Applications**: Applications without complex scaling requirements - **Real-time Processing**: When low latency is critical ## Limitations - **Single Process**: Events don't cross process boundaries - **No Persistence**: Events are lost if the process crashes - **Memory Usage**: All listeners are kept in memory - **No Distribution**: Cannot scale across multiple instances ## Usage ### Publishing Events ```typescript await emit('ORDER_CREATE', { orderId: '12345', userId: 'user123', total: 99.99 }); ``` ### Subscribing to Events ```typescript subscribe('ORDER_CREATE', async (payload) => { const { orderId, userId, total } = payload; await sendOrderConfirmationEmail(userId, orderId); }); ``` ## When to Use Use the Node.js Event Emitter for: - Local development - Testing environments - Simple applications - Single server deployments - When Redis/cloud setup is not feasible ## Adapter Details | Property | Value | |----------|-------| | Source | [events/node-event-emitter.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/node-event-emitter.ts) | ## Related - [Redis Events](./events-redis.md) - Distributed events with Redis - [AWS EventBridge](./events-eventbridge.md) - Cloud-native events - [Plugins Overview](./) - All available plugins --- ## Redis Events Distributed event system using Redis pub/sub for cross-process communication. ## Installation ```typescript ``` :::warning Explicit Configuration Required Unlike the Node.js event emitter (which is the default), this plugin requires explicit configuration. You must call `setEmitAdapter()` to activate Redis as your event system: ```typescript setEmitAdapter(RedisEventEmitter()); ``` ::: ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `REDIS_HOST` | - | Redis server hostname (required) | | `REDIS_PORT` | `6379` | Redis server port | | `REDIS_DB` | `0` | Redis database number | ## Features - **Distributed**: Events work across multiple application instances - **Persistent Connections**: Maintains Redis pub/sub connections - **JSON Serialization**: Automatic payload serialization/deserialization - **Scalable**: Supports horizontal scaling - **Reliable**: Redis provides reliability and persistence options ## Use Cases - **Multi-Instance Deployments**: Applications running on multiple servers - **Microservices**: Communication between different services - **Horizontal Scaling**: When you need to scale beyond a single instance - **Production Deployments**: Robust event handling for production ## Redis Setup ### Docker ```bash docker run -d \ --name redis \ -p 6379:6379 \ redis:alpine ``` ### Docker Compose ```yaml version: '3' services: redis: image: redis:alpine ports: - "6379:6379" ``` ## Configuration ```bash REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=0 ``` ## Usage ### Publishing Events ```typescript // Events are automatically distributed to all instances await emit('ORDER_CREATE', { orderId: '12345', userId: 'user123', total: 99.99 }); ``` ### Subscribing to Events ```typescript // Each instance receives the event subscribe('ORDER_CREATE', async (payload) => { const { orderId, userId, total } = payload; await processOrder(orderId); }); ``` ## Performance - **Pros**: Distributed, reliable, cost-effective - **Cons**: Network latency, requires Redis infrastructure ## When to Use Use Redis Events for: - Horizontal scaling requirements - Multiple application instances - Production deployments - Cost-effective distributed events ## Adapter Details | Property | Value | |----------|-------| | Source | [events/redis.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/redis.ts) | ## Related - [Node.js Events](./events-node.md) - In-memory events - [AWS EventBridge](./events-eventbridge.md) - Cloud-native events - [Plugins Overview](./) - All available plugins --- ## Event Plugins Event plugins provide different backends for the event system. | Import Path | Description | |-------------|-------------| | [`@unchainedshop/plugins/events/node-event-emitter`](./events-node.md) | In-memory events (default) | | [`@unchainedshop/plugins/events/redis`](./events-redis.md) | Distributed events with Redis | | [`@unchainedshop/plugins/events/aws-eventbridge`](./events-eventbridge.md) | AWS EventBridge integration | ## Choosing an Event Backend - **Node Event Emitter**: Best for single-instance deployments. Simple, no external dependencies. - **Redis**: Best for multi-instance deployments. Enables distributed event handling. - **AWS EventBridge**: Best for serverless architectures and AWS-centric deployments. --- ## GridFS File Storage MongoDB GridFS-based file storage for storing files directly in MongoDB. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation **Express:** ```typescript const { GRIDFS_PUT_SERVER_PATH = '/gridfs' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { gridfsFileUploads: gridfsModules, }, }); app.use(GRIDFS_PUT_SERVER_PATH, gridfsHandler); ``` **Fastify:** ```typescript const { GRIDFS_PUT_SERVER_PATH = '/gridfs' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { gridfsFileUploads: gridfsModules, }, }); fastify.register((s, opts, registered) => { // Disable JSON parsing for file uploads s.removeAllContentTypeParsers(); s.addContentTypeParser('*', function (req, payload, done) { done(null); }); s.route({ url: GRIDFS_PUT_SERVER_PATH + '/:directoryName/:fileName', method: ['GET', 'PUT', 'OPTIONS'], handler: gridfsHandler, }); registered(); }); ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `GRIDFS_PUT_SERVER_PATH` | `/gridfs` | URL path for file upload/download endpoint | ## Features - **MongoDB Integration**: Native MongoDB file storage - **No External Dependencies**: Uses existing MongoDB connection - **Streaming Support**: Efficient streaming for large files - **Metadata Storage**: Rich metadata support - **Automatic Cleanup**: Built-in file cleanup and management - **Simple Setup**: No additional services required ## Use Cases - **Development**: Quick setup without external services - **Small Deployments**: When S3/Minio is overkill - **Self-Contained**: When you want everything in MongoDB ## Usage ### Upload from Stream ```typescript const fileData = await fileAdapter.uploadFileFromStream( 'documents', fileStream ); ``` ### Upload from URL ```typescript const fileData = await fileAdapter.uploadFileFromURL( 'product-images', { fileLink: 'https://example.com/image.jpg', fileName: 'product-image.jpg' } ); ``` ### Download File ```typescript const stream = await fileAdapter.createDownloadStream({ fileId: file._id }); ``` ## Limitations - **Scalability**: Limited by MongoDB storage - **CDN Integration**: Requires manual setup - **File Size**: 16MB per chunk limit - **Performance**: Not optimized for high-traffic file serving ## GridFS vs MinIO/S3 | Feature | GridFS | MinIO/S3 | |---------|--------|----------| | External Service | No | Yes | | Scalability | MongoDB limits | Virtually unlimited | | CDN Integration | Manual | Easy | | Development Setup | Simple | Requires MinIO/S3 | | Production Scaling | Limited | Excellent | | File Size Limits | 16MB per chunk | None | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.file-upload-plugin.gridfs` | | Source | [files/gridfs/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/files/gridfs/) | ## Related - [MinIO/S3 Storage](./file-minio.md) - S3-compatible storage - [File Uploads Guide](../../guides/file-uploads.md) - File upload implementation - [Plugins Overview](./) - All available plugins --- ## MinIO/S3 File Storage S3-compatible object storage using the MinIO client, supporting both MinIO and Amazon S3. :::warning GridFS Conflict If you're using a preset that includes GridFS (like `base` or `all`), you must unregister the GridFS adapter before using MinIO: ```typescript // Unregister GridFS adapter loaded by presets FileDirector.unregisterAdapter('shop.unchained.file-upload-plugin.gridfs'); ``` ::: ## Installation ```typescript ``` The plugin automatically registers when environment variables are configured. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `MINIO_ENDPOINT` | - | MinIO/S3 endpoint URL (required) | | `MINIO_BUCKET_NAME` | - | Storage bucket name (required) | | `MINIO_ACCESS_KEY` | - | Access key for authentication | | `MINIO_SECRET_KEY` | - | Secret key for authentication | | `MINIO_REGION` | - | Storage region | | `MINIO_UPLOAD_PREFIX` | - | Prefix for uploaded file paths | | `MINIO_STS_ENDPOINT` | - | STS endpoint for temporary credentials | | `AMAZON_S3_SESSION_TOKEN` | - | AWS session token for temporary access | ## Features - **S3 Compatibility**: Works with Amazon S3, MinIO, and other S3-compatible services - **Signed URLs**: Pre-signed URLs for secure direct uploads - **Streaming**: Support for streaming uploads and downloads - **File Management**: Upload, download, and delete operations - **Multi-format Support**: Automatic MIME type detection - **Temporary Credentials**: Support for AWS STS temporary credentials ## Use Cases - **Production Deployments**: Scalable file storage - **CDN Integration**: Easy integration with CloudFront or other CDNs - **Large Files**: No file size limits - **High Traffic**: Optimized for file serving at scale ## Usage ### Upload from Stream ```typescript const fileData = await fileAdapter.uploadFileFromStream( 'product-images', fileStream ); ``` ### Upload from URL ```typescript const fileData = await fileAdapter.uploadFileFromURL( 'product-images', { fileLink: 'https://example.com/image.jpg', fileName: 'product-image.jpg' } ); ``` ### Create Signed Upload URL ```typescript const signedUrl = await fileAdapter.createSignedURL( 'product-images', 'new-image.jpg' ); ``` ### Download File ```typescript const downloadUrl = await fileAdapter.createDownloadURL(file); const stream = await fileAdapter.createDownloadStream({ fileId: file._id }); ``` ## Express Handler ```typescript app.use('/files', createMinioExpressHandler()); ``` ## Fastify Handler ```typescript fastify.register(createMinioFastifyHandler); ``` ## Local MinIO Setup ### Docker ```bash docker run -d \ --name minio \ -p 9000:9000 \ -p 9001:9001 \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=minioadmin \ minio/minio server /data --console-address ":9001" ``` ### Docker Compose ```yaml version: '3' services: minio: image: minio/minio command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin volumes: - minio_data:/data volumes: minio_data: ``` ## Configuration Examples ### Local MinIO ```bash MINIO_ENDPOINT=http://localhost:9000 MINIO_BUCKET_NAME=uploads MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ``` ### AWS S3 ```bash MINIO_ENDPOINT=https://s3.amazonaws.com MINIO_BUCKET_NAME=your-bucket MINIO_ACCESS_KEY=AKIA... MINIO_SECRET_KEY=... MINIO_REGION=us-east-1 ``` ## Path Structure Files are organized using the following structure: ``` bucket/ └── [MINIO_UPLOAD_PREFIX]/ └── [directoryName]/ └── [hashedFilename] ``` ## Security - **Pre-signed URLs**: Secure uploads without exposing credentials - **Hashed Filenames**: Automatic filename hashing - **Expiration**: Configurable URL expiration times - **Bucket Policies**: Configure appropriate S3 bucket policies ## Production Considerations - **CDN Integration**: Use CloudFront or similar CDN - **Regional Deployment**: Choose appropriate regions - **CORS**: Set up CORS for frontend uploads - **Encryption**: Enable server-side encryption ## GridFS vs MinIO/S3 | Feature | GridFS | MinIO/S3 | |---------|--------|----------| | External Service | No | Yes | | Scalability | MongoDB limits | Virtually unlimited | | CDN Integration | Manual | Easy | | Development Setup | Simple | Requires MinIO/S3 | | Production Scaling | Limited | Excellent | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.file-upload-plugin.minio` | | Source | [files/minio/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/files/minio/) | ## Related - [GridFS Storage](./file-gridfs.md) - MongoDB-based storage - [File Uploads Guide](../../guides/file-uploads.md) - File upload implementation - [Plugins Overview](./) - All available plugins --- ## File Storage Plugins File storage plugins handle file uploads and storage. | Adapter Key | Description | |-------------|-------------| | [`shop.unchained.file-upload-plugin.gridfs`](./file-gridfs.md) | MongoDB GridFS storage | | [`shop.unchained.file-upload-plugin.minio`](./file-minio.md) | S3/MinIO compatible storage | --- ## Local Search Filter The Local Search filter provides full-text search using MongoDB's built-in text search capabilities. ## Installation ```typescript ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.filters.local-search` | | Order Index | `10` | | Version | `1.0.0` | | Source | [filters/local-search.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/filters/local-search.ts) | ## Requirements - MongoDB text indexes on product and assortment text collections - **Not compatible with AWS DocumentDB** (automatically disabled in DocumentDB compat mode) ## Behavior ### `searchProducts()` Searches product text fields using MongoDB `$text` operator: ```typescript // Search in product texts (title, description, etc.) const selector = { $text: { $search: queryString }, }; // If productIds provided, filter to those products if (productIds) { selector.productId = { $in: productIds }; } ``` ### `searchAssortments()` Searches assortment/category text fields: ```typescript const selector = { $text: { $search: queryString }, }; if (assortmentIds) { selector.assortmentId = { $in: assortmentIds }; } ``` ### `transformFilterSelector()` For global searches (no specific assortment), returns all active filters: ```typescript // When searching globally, show all filters if (queryString && !filterIds) { return { isActive: true }; } ``` ## Query Examples ### Basic Text Search ```graphql query SearchProducts { searchProducts(queryString: "running shoes") { products { _id texts { title description } } filteredProductsCount } } ``` ### Combined Search and Filter ```graphql query SearchWithFilters { searchProducts( queryString: "organic cotton" filterQuery: [ { key: "category", value: "clothing" } { key: "size", value: "M" } ] ) { products { _id texts { title } } filters { filteredProductsCount isSelected options { filteredProductsCount isSelected } } } } ``` ### Search Assortments ```graphql query SearchCategories { searchAssortments(queryString: "summer collection") { assortments { _id texts { title } } } } ``` ## Text Index Configuration MongoDB text indexes are created automatically. The default indexes search: **Product Texts:** - `title` - `subtitle` - `description` - `vendor` - `brand` - `labels` **Assortment Texts:** - `title` - `subtitle` - `description` ## Search Features ### Phrase Search ``` # Exact phrase searchProducts(queryString: "\"running shoes\"") ``` ### Negation ``` # Exclude terms searchProducts(queryString: "shoes -sandals") ``` ### Stemming MongoDB applies stemming based on language: - "running" matches "run", "runs", "runner" ## DocumentDB Compatibility Local Search is automatically disabled when `UNCHAINED_DOCUMENTDB_COMPAT_MODE` is set: ```bash UNCHAINED_DOCUMENTDB_COMPAT_MODE=true ``` In this case, implement an alternative search adapter using a service like: - Elasticsearch - Algolia - Meilisearch - OpenSearch ## Custom Search Adapter For external search services: ```typescript const AlgoliaSearchAdapter: IFilterAdapter = { ...FilterAdapter, key: 'my-shop.algolia-search', label: 'Algolia Search', version: '1.0.0', orderIndex: 10, actions: (params) => { const { searchQuery, modules } = params; return { ...FilterAdapter.actions(params), async searchProducts({ productIds }) { const { queryString } = searchQuery; if (!queryString) return productIds; const algoliaClient = algoliasearch( process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_API_KEY ); const index = algoliaClient.initIndex('products'); const { hits } = await index.search(queryString, { filters: productIds ? `productId:${productIds.join(' OR productId:')}` : undefined, hitsPerPage: 1000, }); return hits.map(hit => hit.productId); }, async searchAssortments({ assortmentIds }) { const { queryString } = searchQuery; if (!queryString) return assortmentIds; const algoliaClient = algoliasearch( process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_API_KEY ); const index = algoliaClient.initIndex('assortments'); const { hits } = await index.search(queryString); return hits.map(hit => hit.assortmentId); }, }; }, }; FilterDirector.registerAdapter(AlgoliaSearchAdapter); ``` ## Performance Considerations 1. **Index optimization**: Ensure text indexes exist and are properly configured 2. **Query limits**: Use pagination for large result sets 3. **Projection**: Only request needed fields 4. **Caching**: Consider caching frequent searches ## Related - [Plugins Overview](./) - All available plugins - [Strict Equal Filter](./filter-strict-equal.md) - Exact matching - [Search and Filtering Guide](../../guides/search-and-filtering.md) - Implementation guide - [Custom Filter Plugins](../../extend/catalog/filter.md) - Write your own --- ## Strict Equal Filter The Strict Equal filter provides simple exact-match filtering on product fields. ## Installation ```typescript ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.filters.strict-qual` | | Order Index | `0` (runs first) | | Version | `1.0.0` | | Source | [filters/strict-equal.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/filters/strict-equal.ts) | ## Behavior ### `transformProductSelector()` Transforms the MongoDB selector to match exact values: ```typescript // Input: { key: "brand", value: "nike" } // Output: { brand: "nike" } // Input: { key: "inStock", value: undefined } // Output: { inStock: { $exists: true } } ``` When `value` is provided, it matches exactly. When `value` is undefined, it checks field existence. ## Use Cases ### Single-Choice Filters ```graphql # Create a brand filter mutation CreateBrandFilter { createFilter(filter: { key: "brand" type: SINGLE_CHOICE }) { _id } } ``` ```graphql # Add brand options mutation AddBrandOption { createFilterOption(filterId: "...", option: "nike") { _id } } ``` Products must have matching field: ```typescript // Product document { _id: "product-1", brand: "nike", // Matches filter key } ``` ### Multi-Choice Filters ```graphql mutation { createFilter(filter: { key: "color" type: MULTI_CHOICE }) { _id } } ``` ### Boolean Filters ```graphql mutation { createFilter(filter: { key: "isOrganic" type: SWITCH }) { _id } } ``` ## Query Example ```graphql query FilteredProducts { searchProducts( filterQuery: [ { key: "brand", value: "nike" } { key: "isOrganic", value: "true" } ] ) { products { _id texts { title } } filteredProductsCount } } ``` ## Product Field Mapping The filter key maps directly to product fields. Common patterns: | Filter Key | Product Field | Example Value | |------------|--------------|---------------| | `brand` | `brand` | `"nike"` | | `color` | `color` | `"blue"` | | `size` | `size` | `"M"` | | `category` | `category` | `"clothing"` | | `meta.material` | `meta.material` | `"cotton"` | ## Nested Field Filtering For nested fields, use dot notation: ``` filterQuery: [ { key: "meta.material", value: "cotton" } { key: "specs.weight", value: "500g" } ] ``` ## Limitations - **Exact matching only**: No partial matches, ranges, or fuzzy search - **Case-sensitive**: `"Nike"` β‰  `"nike"` - **Single value**: Each filter key matches one value For more complex filtering, create a custom filter adapter or use [Local Search](./filter-local-search.md) for text queries. ## Combining with Other Filters Strict Equal runs at `orderIndex: 0`, so it processes first. Subsequent adapters receive its transformed selector: ```typescript // Strict Equal output { brand: "nike", color: "blue" } // Next adapter (e.g., Local Search) receives this and can add more conditions { brand: "nike", color: "blue", $text: { $search: "running shoes" } } ``` ## Related - [Plugins Overview](./) - All available plugins - [Local Search](./filter-local-search.md) - Full-text search - [Custom Filter Plugins](../../extend/catalog/filter.md) - Write your own --- ## Filter Plugins Filter plugins implement product search and filtering functionality. | Adapter Key | Description | |-------------|-------------| | [`shop.unchained.filters.local-search`](./filter-local-search.md) | MongoDB full-text search | | [`shop.unchained.filters.strict-equal`](./filter-strict-equal.md) | Exact match filtering | ## Creating Custom Filter Plugins See [Custom Filter Plugins](../../extend/catalog/filter.md) for creating your own filter adapters. --- ## Plugins Unchained Engine uses a plugin architecture to extend functionality. Plugins are organized by category: ## Categories | Category | Description | |----------|-------------| | [Payment](./payment/) | Payment service provider integrations (Stripe, PayPal, etc.) | | [Delivery](./delivery/) | Shipping and fulfillment methods | | [Pricing](./pricing/) | Price calculation, taxes, discounts, and rounding | | [Warehousing](./warehousing/) | Inventory management and stock handling | | [Filters](./filters/) | Product search and filtering | | [File Storage](./files/) | File upload backends (GridFS, S3/MinIO) | | [Workers](./workers/) | Background tasks (email, SMS, webhooks) | | [Events](./events/) | Event system backends (Node, Redis, EventBridge) | | [Quotations](./quotations/) | Price quotation and custom offering handling | | [Enrollments](./enrollments/) | Subscription and recurring order management | ## Quick Setup For quick setup, use [Plugin Presets](../platform-configuration/plugin-presets.md) which bundle commonly used plugins together. ## Writing Custom Plugins For creating your own plugins, see the Extending documentation: - [Custom Payment Plugins](../extend/order-fulfilment/fulfilment-plugins/payment.md) - [Custom Delivery Plugins](../extend/order-fulfilment/fulfilment-plugins/delivery.md) - [Custom Warehousing Plugins](../extend/order-fulfilment/fulfilment-plugins/warehousing.md) - [Custom Filter Plugins](../extend/catalog/filter.md) - [Custom Pricing Plugins](../extend/pricing/product-pricing.md) - [Custom Worker Plugins](../extend/worker.md) - [Custom Quotation Plugins](../extend/quotation.md) - [Custom Enrollment Plugins](../extend/enrollment.md) --- ## Apple In-App Purchase Unchained payment plugin for Apple In-App Purchase (IAP), enabling iOS apps to process payments through Apple's payment system with receipt validation. - [Apple In-App Purchase Documentation](https://developer.apple.com/in-app-purchase/) - [StoreKit Framework](https://developer.apple.com/documentation/storekit) - [Receipt Validation](https://developer.apple.com/documentation/appstorereceipts/verifying_receipts_with_the_app_store) - [Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications) ## Installation **Express:** ```typescript const { APPLE_IAP_WEBHOOK_PATH = '/payment/apple-iap' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { appleTransactions: appleTransactionsModule, }, }); app.use(APPLE_IAP_WEBHOOK_PATH, express.json({ strict: false }), appleIAPHandler); ``` **Fastify:** ```typescript const { APPLE_IAP_WEBHOOK_PATH = '/payment/apple-iap' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { appleTransactions: appleTransactionsModule, }, }); fastify.route({ url: APPLE_IAP_WEBHOOK_PATH, method: 'POST', handler: appleIAPHandler, }); ``` ## Create Provider ```graphql mutation CreateAppleIAPProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.apple-iap" } ) { _id } } ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `APPLE_IAP_SHARED_SECRET` | - | Your Apple App Store shared secret (required) | | `APPLE_IAP_WEBHOOK_PATH` | `/payment/apple-iap` | Webhook endpoint path | ## Apple App Store Setup 1. Configure In-App Purchases in App Store Connect 2. Create products with unique product identifiers 3. Generate a shared secret in App Store Connect 4. Implement StoreKit in your iOS app 5. Set up receipt validation endpoints ## Usage ### Payment Flow 1. **iOS App Purchase**: User initiates purchase through your iOS app using StoreKit 2. **Receipt Validation**: After successful purchase, register the receipt: ```graphql mutation RegisterReceipt { signPaymentProviderForCredentialRegistration( paymentProviderId: "apple-iap-provider-id" transactionContext: { receiptData: "base64-encoded-receipt-data" } ) } ``` 3. **Create Order**: Create an order with the purchased product and set transaction metadata: ```graphql mutation UpdatePayment { updateCartPaymentGeneric( paymentProviderId: "apple-iap-provider-id" meta: { transactionIdentifier: "apple-transaction-id" } ) { _id } } ``` 4. **Complete Purchase**: Checkout the order: ```graphql mutation CheckoutCart { checkoutCart( orderId: "order-id" paymentContext: { transactionIdentifier: "apple-transaction-id" receiptData: "base64-encoded-receipt-data" # optional if already registered } ) { _id status } } ``` ### Order Limitations - **Single Product Orders**: Only one unique product can be purchased per order - **Quantity Matching**: Order quantity must match the transaction quantity - **Product ID Matching**: Order product ID must match the transaction product ID ### Receipt Validation The plugin performs comprehensive receipt validation: - Verifies receipt authenticity with Apple's servers - Checks transaction status and validity - Prevents duplicate transaction processing - Matches transaction details with order contents ### Transaction Tracking The plugin maintains a transaction database to: - Prevent processing the same transaction multiple times - Track which transactions have been processed - Store transaction metadata for auditing ## Features - **Receipt Validation**: Server-side validation with Apple's receipt verification service - **Duplicate Prevention**: Automatic detection and prevention of duplicate transactions - **Product Matching**: Ensures purchased products match order contents - **Transaction Tracking**: Complete audit trail of processed transactions - **Error Handling**: Comprehensive error messages for debugging ## Security - **Server-Side Validation**: All receipt validation happens server-side - **Shared Secret**: Uses Apple's shared secret for secure validation - **Transaction Deduplication**: Prevents replay attacks and duplicate processing - **Product Verification**: Ensures purchased products exist and match orders ## Integration Notes - The plugin does not support payment signing (throws an error if attempted) - Payment credentials are considered valid once registered with a valid receipt - Transaction identifiers must be set on order payments before checkout - Receipt data can be provided during registration or checkout - Orders are limited to one unique product per transaction ## Apple App Store Requirements - Configure In-App Purchases in App Store Connect - Generate and configure a shared secret - Implement proper StoreKit integration in your iOS app - Handle receipt validation and transaction completion - Follow Apple's In-App Purchase guidelines ## Testing Use Apple's sandbox environment for testing: - Test with sandbox iTunes accounts - Use sandbox receipt data for validation - Verify transaction flows in the sandbox environment - Test various purchase scenarios and edge cases ## Common Use Cases - **Digital Products**: Selling digital content, premium features, or subscriptions - **Mobile Apps**: iOS apps requiring payment processing through Apple's system - **Content Unlocking**: Unlocking premium content or features after purchase - **Subscription Services**: Recurring payments through Apple's subscription system ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.apple-iap` | | Type | `GENERIC` | | Source | [payment/apple-iap/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/apple-iap/) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide --- ## Braintree Unchained payment plugin for Braintree, a PayPal-owned payment processor that supports various payment methods including PayPal, credit cards, and digital wallets. - [Braintree Developer Documentation](https://developer.paypal.com/braintree/docs) - [Braintree Node.js SDK](https://github.com/braintree/braintree_node) - [Braintree Client SDK](https://developer.paypal.com/braintree/docs/start/overview) :::info Not Included in Presets This plugin is **not** included in the default plugin presets. You need to import it explicitly in your project. ::: ## Installation ```typescript ``` Requires the `braintree` npm package as a peer dependency: ```bash npm install braintree ``` ## Create Provider ```graphql mutation CreateBraintreeProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.braintree-direct" } ) { _id } } mutation ConfigureBraintreeProvider { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "publicKey", value: "your-public-key" } { key: "merchantId", value: "your-merchant-id" } ] } ) { _id } } ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `BRAINTREE_SANDBOX_TOKEN` | - | Access token for sandbox environment (testing) | | `BRAINTREE_PRIVATE_KEY` | - | Private key for production environment | ## Provider Configuration | Key | Description | |-----|-------------| | `publicKey` | Your Braintree public key (required) | | `merchantId` | Your Braintree merchant ID (required) | ## Environment Setup **Sandbox Mode (Testing):** - Set `BRAINTREE_SANDBOX_TOKEN` environment variable - The plugin will automatically use sandbox mode when this token is present **Production Mode:** - Remove `BRAINTREE_SANDBOX_TOKEN` or leave it empty - Configure `publicKey`, `merchantId` in the provider configuration - Set `BRAINTREE_PRIVATE_KEY` environment variable ## Usage ### Payment Flow 1. **Get Client Token**: Use `signPaymentProviderForCheckout` to get a client token: ```graphql mutation GetClientToken { signPaymentProviderForCheckout( orderPaymentId: "order payment id of the cart you want to checkout" ) } ``` 2. **Initialize Braintree Client**: Use the returned client token to initialize the Braintree client-side SDK 3. **Process Payment**: After collecting payment method nonce from Braintree SDK, checkout with: ```graphql mutation CheckoutCart { checkoutCart( orderId: "order-id" paymentContext: { paypalPaymentMethodNonce: "nonce-from-braintree-sdk" } ) { _id status } } ``` ### Payment Method Support The plugin currently supports: - PayPal payments via Braintree - Credit card processing through Braintree SDK - Various payment methods supported by Braintree ### Currency and Merchant Accounts - The plugin uses the order's currency code as the merchant account ID - Amounts are automatically rounded to the nearest 10 cents - Billing address information is automatically passed to Braintree for fraud prevention ## Features - **Automatic Settlement**: Payments are submitted for settlement immediately - **Address Integration**: Billing addresses are automatically sent to Braintree - **Error Handling**: Comprehensive error handling and logging - **Development Support**: Easy sandbox mode for testing ## Integration Notes - The plugin expects a `paypalPaymentMethodNonce` in the payment context during checkout - Order numbers are passed to Braintree for tracking (falls back to order ID if no order number) - The plugin requires the Braintree Node.js SDK as a peer dependency ## Testing Use the `BRAINTREE_SANDBOX_TOKEN` for testing. You can obtain this token from your Braintree sandbox account dashboard. ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.braintree-direct` | | Type | `GENERIC` | | Source | [payment/braintree.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/braintree.ts) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide --- ## Cryptopay The Cryptopay plugin allows you to accept payments in Bitcoin, Ethereum and arbitrary ERC20 tokens without relying on centralized payment providers and without your private key, it directly connects to the Bitcoin/Ethereum Node API's. It consists of two parts: - A payment plugin that generates new addresses for every order and updates the payment status when the order is paid. - A price feed plugin that continuously gets the [Chainlink](https://chain.link/) price feeds to convert between the different currencies. Because the plugin is using the currency rate system of Unchained with support for arbitrary rate plugins, it can also be used in conjunction with other price feeds (e.g., Coinbase) if desired. - [Chainlink Documentation](https://chain.link/) - [Unchained Cryptopay Gateway](https://github.com/unchainedshop/unchained-cryptopay) ## Installation **Express:** ```typescript const { CRYPTOPAY_WEBHOOK_PATH = '/payment/cryptopay' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { ...cryptopayModules, }, }); app.use(CRYPTOPAY_WEBHOOK_PATH, express.json(), cryptopayHandler); ``` **Fastify:** ```typescript const { CRYPTOPAY_WEBHOOK_PATH = '/payment/cryptopay' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { ...cryptopayModules, }, }); fastify.route({ url: CRYPTOPAY_WEBHOOK_PATH, method: 'POST', handler: cryptopayHandler, }); ``` ## Create Provider ```graphql mutation CreateCryptopayProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.cryptopay" } ) { _id } } ``` ## Environment Variables You have to set `CRYPTOPAY_SECRET`, `CRYPTOPAY_BTC_XPUB` (if you want to accept Bitcoin payments), and `CRYPTOPAY_ETH_XPUB` (if you want to accept Ethereum payments): | NAME | Default Value | Description | | ------------------------- | -------------------------------------- | --------------------------------------- | | `CRYPTOPAY_SECRET` | | Shared secret for communication with the payment gateway. Has to be equal to `unchained.secret` in the payment gateway configuration (`cryptopay.yaml`). | | `CRYPTOPAY_WEBHOOK_PATH` | `/payment/cryptopay` | The path that is used for the payment webhook. Has to correspond to the path in `unchained.transaction-webhook-url` of the payment gateway configuration (`cryptopay.yaml`). | | `CRYPTOPAY_BTC_XPUB` | | Extended Bitcoin public key. | | `CRYPTOPAY_ETH_XPUB` | | Extended Ethereum public key. | | `CRYPTOPAY_MAX_RATE_AGE` | `360` | Maximum age of an exchange rate (in seconds) such that it is still considered for the conversion. | ### Ethereum Address Derivation In contrast to Bitcoin, many Ethereum wallets do not expose the extended public key to you. However, it is very easy to generate a wallet and retrieve it with Python or JavaScript. In JavaScript when using `ethers.js`, the code to do so looks like this: ```javascript let HDNode = require('ethers').utils.HDNode; let mnemonic = ""; let masterNode = HDNode.fromMnemonic(mnemonic); let hardenedMaster = masterNode.derivePath("m/44'/60'/0'"); // Extended public key (of hardened master node, i.e. path "m/44'/60'/0'") let xpub = hardenedMaster.neuter().extendedKey; ``` Similarly, `bip-utils` can be used in Python: ```python from bip_utils import Bip39SeedGenerator, Bip44Coins, Bip44 # Generate from mnemonic mnemonic = "" seed_bytes = Bip39SeedGenerator(mnemonic).Generate() # Or specify seed manually # seed_bytes = binascii.unhexlify(b"") # Derivation path returned: m bip44_mst_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) xpub = bip44_mst_ctx.Purpose().Coin().Account(0).PublicKey().ToExtended() # m/44'/60'/0' ``` **Note that for security reasons, the extended public key should never be generated on a system that is publicly accessible.** You should always do this offline and only reference the extended public key on publicly accessible systems. Then, an attacker cannot access your funds, even if your system is completely compromised. ## Usage The payment plugin supports products that have a crypto price (including ERC20 tokens, where the `contractAddress` is set on the currency), but also those that only have a fiat price. When the product has a crypto price, it is assumed that the price is recorded with 8 decimal places. For instance, the following `UpdateProductCommercePricingInput` would be used for a product that costs 1 ETH: ```javascript { amount: 10 ** 8, maxQuantity: 0, isTaxable: false, isNetPrice: false, currencyCode: 'ETH', countryCode: 'CH', } ``` When a product only has a fiat price (e.g., `USD`), the paid crypto amount is converted to the fiat currency using Unchained's rate system. Because rates for cryptocurrencies can be very volatile, the behavior of the engine for the conversion is configurable. With `CRYPTOPAY_MAX_RATE_AGE`, you can configure the maximum age (in seconds) for the exchange rate such that it is still considered for the conversion. When using the Cryptopay pricing plugin (see below), the rates are updated every 10 seconds during normal operation. With the parameter `CRYPTOPAY_MAX_CONV_DIFF`, you can configure if an order should be considered as paid even if the converted amount is lower than the fiat price of the product. This can happen when the exchange rate changes between the generation of the address and the payment (including confirmation on the blockchain) of the user. A value of `0.00` means that the converted amount always has to be equal or higher than the configured fiat price of the product. ### Deriving Addresses for an Order To get the addresses that belong to the order and are displayed to the end user, the `signPaymentProviderForCheckout` mutation is used: ```/*graphql*/ signPaymentProviderForCheckout( orderPaymentId: "order payment id of the cart you want to checkout" ) ``` *To get the order payment id of the current active cart of the logged in user you can* ```/*graphql*/ me { cart { payment { _id } } } ``` The mutation returns a stringified JSON-object with the different addresses: ```json { "data": { "signPaymentProviderForCheckout": "[{\"currency\":\"BTC\",\"address\":\"mkFQhpfDfW9tqybJA47b71Wxq3XKV2DSwT\"},{\"currency\":\"ETH\",\"address\":\"0xaBC2bCA51709b8615147352C62420F547a63A00c\"}]" } } ``` Note that only addresses are returned if the corresponding extended public key is set. If you only set `CRYPTOPAY_ETH_XPUB`, the array will therefore only contain an Ethereum address. The plugin ensures that when calling the mutation multiple times, the returned addresses are always identical for a given `orderPaymentId`. When integrating it into the frontend, the addresses can therefore be shown in multiple places (e.g., at the checkout stage and inside the orders overview in the account page to allow deferred payments with crypto) and always be retrieved by calling the mutation. ### Accepting Payments in ERC20 tokens If you want to accept payments in a token that follows the ERC20 standard, you have to create a corresponding currency and provide a `contractAddress`, e.g.: ```/*graphql*/ mutation createMATIC { createCurrency(currency: {isoCode: "MATIC", decimals: 18, contractAddress: "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0"}) { _id } } ``` You can then set prices for products in this currency and use it for payments that are converted to fiat (with arbitrary price feeds, e.g. the decentralized Cryptopay feeds based on Chainlink). Note that for security reasons, payments are only accepted for the ERC20 tokens that you have added to your store as a currency. # Pricing Plugin The Cryptopay pricing plugin provides rates for the Unchained rate system and can therefore be used by the payment plugin for the rate conversions. The gateway sends the current rate for BTC, ETH, and the configured ERC20 tokens to the plugin every 10 seconds. If no Chainlink price feed exists for a given currency pair (e.g., if you have an online shop that only has `CHF` prices and want to accept payments in `MATIC`, but there is no direct `MATIC` / `CHF` feed), the gateway tries to use `USD` as an intermediate currency in the calculation (e.g., the `MATIC` / `USD` and `CHF` / `USD` feeds are used in the previous example to calculate the `MATIC` / `CHF` rate). This happens automatically in the background and you do not have to worry about it. ## Environment variables | NAME | Default Value | Description | | --------------------------------- | -------------------------------------- | --------------------------------------- | | `CRYPTOPAY_SECRET` | | Shared secret for communication with the payment gateway. Has to be equal to `unchained.secret` in the payment gateway configuration (`cryptopay.yaml`). | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.payment.cryptopay` | | Type | `GENERIC` | | Source | [payment/cryptopay/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/cryptopay/) | ## Related - [Plugins Overview](./) - All available plugins - [Multi-Currency Setup](../../guides/multi-currency-setup.md) - Currency configuration - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide --- ## Datatrans Unchained payment plugin for Datatrans, a Swiss payment service provider supporting multiple payment methods. - [Datatrans API Documentation](https://docs.datatrans.ch/docs/home) - [Datatrans Payment Process](https://docs.datatrans.ch/docs/payment-process-overview) - [Datatrans Webhooks](https://docs.datatrans.ch/docs/webhook) ## Installation **Express:** ```typescript const { DATATRANS_WEBHOOK_PATH = '/payment/datatrans/webhook' } = process.env; // IMPORTANT: Use express.text for Datatrans signature verification app.use(DATATRANS_WEBHOOK_PATH, express.text({ type: 'application/json' }), datatransHandler); ``` **Fastify:** ```typescript const { DATATRANS_WEBHOOK_PATH = '/payment/datatrans/webhook' } = process.env; fastify.register((s, opts, registered) => { s.addContentTypeParser( 'application/json', { parseAs: 'string', bodyLimit: 1024 * 1024 }, s.defaultTextParser, ); s.route({ url: DATATRANS_WEBHOOK_PATH, method: 'POST', handler: datatransHandler, }); registered(); }); ``` ## Create Provider ```graphql mutation CreateDatatransProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.datatrans" } ) { _id } } mutation ConfigureDatatransProvider { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "merchantId", value: "your-merchant-id" } ] } ) { _id } } ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `DATATRANS_SECRET` | - | API secret (required) | | `DATATRANS_SIGN_KEY` | - | Signing key (required) | | `DATATRANS_SIGN2_KEY` | `{DATATRANS_SIGN_KEY}` | Secondary signing key | | `DATATRANS_SECURITY` | `dynamic-sign` | `''`, `'static-sign'`, `'dynamic-sign'` | | `DATATRANS_API_ENDPOINT` | `https://api.sandbox.datatrans.com` | API endpoint (use non-sandbox for production) | | `DATATRANS_WEBHOOK_PATH` | `/payment/datatrans/webhook` | Webhook endpoint path | | `DATATRANS_SUCCESS_PATH` | `/payment/datatrans/success` | Success redirect path | | `DATATRANS_ERROR_PATH` | `/payment/datatrans/error` | Error redirect path | | `DATATRANS_CANCEL_PATH` | `/payment/datatrans/cancel` | Cancel redirect path | | `DATATRANS_RETURN_PATH` | `/payment/datatrans/return` | Return redirect path | | `DATATRANS_MERCHANT_ID` | - | Default merchant ID (fallback if not set in provider config) | ## Provider Configuration | Key | Default | Description | |-----|---------|-------------| | `merchantId` | - | Datatrans merchant ID (required) | | `settleInUnchained` | `1` | Enable settlement in Unchained (`"1"` or `""`) | | `marketplaceSplit` | - | Marketplace split config: `"SUBMERCHANTID;DISCOUNT_ADAPTER_KEY;SHARE_PERCENTAGE"` | ### Marketplace Integration Unchained supports Datatrans Marketplace payments. See [Datatrans Marketplace docs](https://docs.datatrans.ch/docs/marketplace-payments#section-settlement-splits) for details. - Add multiple `marketplaceSplit` entries for multi-merchant splits - `settleInUnchained` must be `1` for marketplace features - The marketplace feature requires a custom discount adapter to pre-calculate commissions # Usage in the Frontend You can easily follow the documentation on [redirect lightbox](https://docs.datatrans.ch/docs/redirect-lightbox) and [secure fields](https://docs.datatrans.ch/docs/secure-fields). ## Mode: Redirect / Lightbox Instructions Follow [secure fields](https://docs.datatrans.ch/docs/secure-fields) and where it says you have to initialize a transaction you have to call one of these mutations: **Cart Checkout**: ```/*graphql*/ signPaymentProviderForCheckout( orderPaymentId: "order payment id of the cart you want to checkout" ) ``` _To get the order payment id of the current active cart of the logged in user you can_ ```/*graphql*/ me { cart { payment { _id } } } ``` **Payment credentials registration (without payment/checkout)**: ```/*graphql*/ signPaymentProviderForCredentialRegistration( paymentProviderId: "payment provider id that you instantiated before" ) ``` For both `signPaymentProviderForCheckout` and `signPaymentProviderForCredentialRegistration` you will receive a JSON stringified object that looks like: ``` { location: "https://pay.sandbox.datatrans.com/v1/start/xyz1234..", transactionId: "xyz1234.." } ``` That's when you can either redirect to the location for "Redirect" mode or use the transactionId with the "Lightbox" mode to finalize the Payment as shown here: https://docs.datatrans.ch/docs/redirect-lightbox#section-redirect-integration and https://docs.datatrans.ch/docs/redirect-lightbox#section-lightbox-integration. When a successful payment is finished, Datatrans will call the Datatrans webhook of Unchained Engine server-side (`DATATRANS_WEBHOOK_PATH`), Unchained Engine will look up the transaction, do some validity checks and then call `checkoutCart` for you, At `checkoutCart` stage, Unchained Engine will settle the payment and also store the payment credential alias for convenience (fast) in further checkouts. Datatrans will also almost immediately redirect to `DATATRANS_SUCCESS_PATH` with the transactionId and in the query parameter. If for some reason the webhook has not been called at all or failed a checkout server-side at a very early stage, it could happen that when success path is loaded, the cart is not checked out yet but the payment is already authorized and authenticated (not settled). In those cases you should fallback to client-side cart checkout by calling: ```/*graphql*/ checkoutCart( orderId: "order id from query parameter", paymentContext: { transactionId: "transaction id from query parameter" }) { _id, status } ``` This gives Unchained Engine a (second) chance to process and settle the payment. That's how you build rock-solid payment flows in shaky networks. # Mode: Secure Fields To let Unchained call the `secureFieldsInit` method during transaction creation, provide `{ "useSecureFields": true }` via the `transactionContext` field to `signPaymentProviderForCheckout` or `signPaymentProviderForCredentialRegistration`. Also you will have to `authorize-split` a secure fields transaction in order to checkout, for that you will have to call `checkoutCart` after form submission with a special object `authorizeAuthenticated`: ```/*graphql*/ checkoutCart( orderId: "order id from query parameter", paymentContext: { transactionId: "transaction id from query parameter", "authorizeAuthenticated": { "CDM": "...", "3D": "..." } }) { _id, status } ``` This will instruct Unchained to authorize an unauthorized transaction before trying to settle it. If you don't have CDM or 3D props to send along, just send an empty object. # Mode: Mobile SDK To enable mobile tokens during checkout as stated [here](https://docs.datatrans.ch/docs/mobile-sdk#section-initializing-transactions), send a special `transactionContext` to `signPaymentProviderForCheckout`: `{ "option": { "returnMobileToken": true } }` # Advanced integration features **Restrict payment method selection in redirect:** You can send any additional properties to /v1/transactions/init by setting properties on the context input fields for eg. if you want to restrict payment methods during checkout you could send `{ "paymentMethods": ["VIS"] }` as value for `transactionContext` in `signPaymentProviderForCheckout` to restrict checkout with that provider to VISA credit cards, **Checkout with alias:** Just simply do `checkoutCart` without initializing a transactionId. If the user has valid stored payment credentials for the datatrans payment provider, the plugin will try to use that information and directly checkout and settle the payment. **Asynchronous Webhook:** As stated [here](https://docs.datatrans.ch/docs/redirect-lightbox#section-webhook) there is the possibility of asynchronous webhooks. Don't enable this, else you will have to "poll" the order status after checkout as webhook-based checkout could still be in-flight and you will miss out on a whole category of errors for the sake of speeding up 1s of processing time. ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.payment.datatrans` | | Type | `GENERIC` | | Source | [payment/datatrans-v2/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/datatrans-v2/) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide - [Checkout Implementation](../../guides/checkout-implementation.md) - Complete checkout flow --- ## Payment Plugins Payment plugins integrate various payment service providers with Unchained Engine. | Adapter Key | Description | Integration Type | Subscriptions | Best For | |-------------|-------------|-----------------|---------------|----------| | [`shop.unchained.payment.stripe`](./stripe.md) | Stripe Payments | Server-side | Yes | Global card payments, most use cases | | [`shop.unchained.datatrans`](./datatrans.md) | Swiss payment service provider | Hosted checkout | No | Swiss merchants needing local methods | | [`shop.unchained.payment.saferpay`](./saferpay.md) | Worldline Saferpay | Hosted checkout | No | European merchants (Worldline ecosystem) | | [`shop.unchained.payment.postfinance-checkout`](./postfinance-checkout.md) | PostFinance Checkout | Hosted checkout | No | PostFinance customers | | [`shop.unchained.payment.payrexx`](./payrexx.md) | Swiss PSP (TWINT, PostFinance) | Hosted checkout | No | TWINT and Swiss payment methods | | [`shop.unchained.braintree-direct`](./braintree.md) | PayPal-owned payment processor | Server-side | No | PayPal integration | | [`shop.unchained.payment.cryptopay`](./cryptopay.md) | Self-hosted crypto payments | Server-side | No | Cryptocurrency payments | | [`shop.unchained.apple-iap`](./apple-iap.md) | Apple In-App Purchase for iOS apps | Native SDK | Yes | iOS in-app purchases | | [`shop.unchained.invoice`](./invoice.md) | Pay-per-invoice (B2B) | Offline | No | B2B invoicing | | [`shop.unchained.invoice-prepaid`](./invoice-prepaid.md) | Prepayment invoice | Offline | No | Prepayment / proforma invoices | ## Creating Custom Payment Plugins See [Custom Payment Plugins](../../extend/order-fulfilment/fulfilment-plugins/payment.md) for creating your own payment adapters. --- ## Invoice Prepaid Payment Prepaid invoice payment plugin that requires payment confirmation before order fulfillment, typically used for prepayment scenarios like bank transfers. ## Installation ```typescript ``` ## Setup ```graphql mutation CreatePrepaidInvoiceProvider { createPaymentProvider( paymentProvider: { type: INVOICE adapterKey: "shop.unchained.invoice-prepaid" } ) { _id } } ``` ## Features - **Pay Later Not Allowed**: Payment must be confirmed before order completion - **Manual Payment Confirmation**: Requires manual confirmation of payment receipt - **Order Hold**: Orders remain pending until payment is confirmed - **Payment Verification**: Manual verification process required ## Use Cases - **Prepayment Required**: Orders requiring payment before processing - **Bank Transfer Payments**: Manual confirmation of wire transfers - **Check Payments**: Orders paid by check requiring manual verification - **High-Value Orders**: Orders requiring payment confirmation before shipping ## Payment Flow 1. **Create Order**: Customer places order 2. **Checkout**: Order enters pending payment status 3. **Payment Request**: Send payment instructions to customer 4. **Payment Confirmation**: Manually confirm payment receipt in admin 5. **Order Processing**: Order moves to confirmed status after payment confirmation ## Confirming Payments Use the admin interface or GraphQL to confirm payments: ```graphql mutation ConfirmPayment { confirmOrder(orderId: "order-id") { _id status } } ``` ## Integration Notes - Orders require manual payment confirmation - Use admin interface to confirm payments - Suitable for one-time customers or high-risk orders - Manual oversight required for each transaction ## Comparison with Standard Invoice | Feature | Standard Invoice | Invoice Prepaid | |---------|------------------|-----------------| | Order Confirmation | Immediate | After payment | | Pay Later | Yes | No | | Payment Processing | External | Manual confirmation | | Use Case | Established customers | New/high-risk customers | | Administrative Overhead | Low | High | | Cash Flow | Delayed | Immediate | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.invoice-prepaid` | | Type | `INVOICE` | | Source | [payment/invoice-prepaid.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/invoice-prepaid.ts) | ## Related - [Invoice Payment](./invoice.md) - Standard invoice - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide --- ## Invoice Payment Standard invoice payment plugin that allows orders to be confirmed immediately with payment processed separately through your invoicing system. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Setup ```graphql mutation CreateInvoiceProvider { createPaymentProvider( paymentProvider: { type: INVOICE adapterKey: "shop.unchained.invoice" } ) { _id } } ``` ## Features - **Pay Later Allowed**: Orders can be confirmed before payment is received - **Immediate Order Confirmation**: Orders are processed immediately upon checkout - **External Payment Processing**: Payment is handled through your separate invoicing system - **No Payment Validation**: No upfront payment verification required ## Use Cases - **B2B Sales**: Business customers with established credit terms - **Traditional Invoicing**: Standard invoice-then-pay workflow - **Credit Customers**: Customers with approved payment terms - **Wholesale Orders**: Large orders with net payment terms ## Payment Flow 1. **Create Order**: Customer places order normally 2. **Checkout**: Order is confirmed immediately 3. **Invoice Generation**: Generate invoice through your external system 4. **Payment Processing**: Handle payment through your invoicing workflow 5. **Order Fulfillment**: Process and ship order ## Integration Notes - Orders are confirmed immediately upon checkout - No payment processing happens in Unchained - Integration with external invoicing systems required - Suitable for established business relationships ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.invoice` | | Type | `INVOICE` | | Source | [payment/invoice.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/invoice.ts) | ## Related - [Invoice Prepaid](./invoice-prepaid.md) - Prepayment required - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide --- ## Payrexx Unchained payment plugin for Payrexx, a Swiss payment service provider supporting various payment methods including credit cards, TWINT, PostFinance, and more. - [Payrexx API Documentation](https://docs.payrexx.com) ## Installation **Express:** ```typescript const { PAYREXX_WEBHOOK_PATH = '/payment/payrexx' } = process.env; app.use(PAYREXX_WEBHOOK_PATH, express.json({ type: 'application/json' }), payrexxHandler); ``` **Fastify:** ```typescript const { PAYREXX_WEBHOOK_PATH = '/payment/payrexx' } = process.env; fastify.route({ url: PAYREXX_WEBHOOK_PATH, method: 'POST', handler: payrexxHandler, }); ``` ## Create Provider ```graphql mutation CreatePayrexxProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.payrexx" } ) { _id } } mutation ConfigurePayrexxProvider { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "instance", value: "your-instance-name" } ] } ) { _id } } ``` ## Configure Payrexx Dashboard 1. Log in to your Payrexx dashboard 2. Go to **Settings** > **Webhooks** 3. Add webhook URL: `https://your-domain.com/payment/payrexx` 4. Enable transaction notifications ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `PAYREXX_SECRET` | - | Your Payrexx API secret key (required) | | `PAYREXX_WEBHOOK_PATH` | `/payment/payrexx` | Webhook endpoint path | | `EMAIL_WEBSITE_URL` | - | Base URL for redirects (e.g., `https://shop.example.com`) | | `EMAIL_WEBSITE_NAME` | `Unchained` | Shop name shown in payment purpose | | `DATATRANS_SUCCESS_PATH` | `/payrexx/success` | Path for successful payment redirect | | `DATATRANS_ERROR_PATH` | `/payrexx/error` | Path for failed payment redirect | | `DATATRANS_CANCEL_PATH` | `/payrexx/cancel` | Path for cancelled payment redirect | ## Provider Configuration | Key | Description | |-----|-------------| | `instance` | Your Payrexx instance name (required) | ## Payment Flow ### 1. Sign for Checkout Create a Payrexx gateway for the order: ```graphql mutation SignPayment { signPaymentProviderForCheckout(orderPaymentId: "order-payment-id") } ``` Returns a JSON string containing: ```json { "id": "gateway-id", "status": "waiting", "link": "https://instance.payrexx.com/pay?gateway=..." } ``` ### 2. Redirect to Payment Parse the response and redirect the user to the `link` URL: ```typescript const gateway = JSON.parse(signResult); window.location.href = gateway.link; ``` ### 3. Handle Redirect The user is redirected back to your configured paths: - **Success**: `EMAIL_WEBSITE_URL + DATATRANS_SUCCESS_PATH` - **Error**: `EMAIL_WEBSITE_URL + DATATRANS_ERROR_PATH` - **Cancel**: `EMAIL_WEBSITE_URL + DATATRANS_CANCEL_PATH` ### 4. Complete via Webhook The webhook automatically completes the checkout when payment is confirmed: ```typescript // Webhook handler calls internally: await services.orders.checkoutOrder(orderId, { paymentContext: { gatewayId: 'payrexx-gateway-id' } }); ``` ### 5. Manual Checkout (Alternative) If not using webhooks, complete manually: ```graphql mutation CheckoutWithPayrexx { checkoutCart( paymentContext: { gatewayId: "payrexx-gateway-id" } ) { _id status orderNumber } } ``` ## Payment States | Payrexx Status | Description | Unchained Action | |----------------|-------------|------------------| | `waiting` | Awaiting payment | No action | | `reserved` | Payment authorized | Ready for checkout | | `confirmed` | Payment captured | Order confirmed | ## Pre-Authorization Flow Payrexx uses reservation mode by default: 1. **Reserve**: Payment is authorized at checkout 2. **Confirm**: Call `confirmOrder` to capture the payment 3. **Cancel**: Call `rejectOrder` to release the reservation ```graphql mutation ConfirmOrder { confirmOrder(orderId: "order-id") { _id status } } ``` ## Redirect URLs After payment, users are redirected with the transaction ID: ``` https://shop.example.com/payrexx/success?transactionId=abc123 ``` Use this to show appropriate confirmation or error pages. ## Features - Multiple payment methods (cards, TWINT, PostFinance, etc.) - Pre-authorization with deferred capture - Automatic webhook processing - Price validation - Reservation cancellation on validation failure ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.payment.payrexx` | | Type | `GENERIC` | | Source | [payment/payrexx/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/payrexx/) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide - [Checkout Implementation](../../guides/checkout-implementation.md) - Complete checkout flow --- ## PostFinance Checkout The Unchained plugin implements the PostFinance Checkout payment service with support for all payment methods, different integration modes (payment page, lightbox, and iFrame), deferred settlements, and refunds. - [PostFinance Checkout Documentation](https://checkout.postfinance.ch/de-ch/doc/api/web-service) - [PostFinance Checkout Integration Guide](https://checkout.postfinance.ch/de-ch/doc/payment-integration) ## Installation **Express:** ```typescript const { PFCHECKOUT_WEBHOOK_PATH = '/payment/postfinance-checkout' } = process.env; app.use(PFCHECKOUT_WEBHOOK_PATH, express.json(), postfinanceCheckoutHandler); ``` **Fastify:** ```typescript const { PFCHECKOUT_WEBHOOK_PATH = '/payment/postfinance-checkout' } = process.env; fastify.route({ url: PFCHECKOUT_WEBHOOK_PATH, method: 'POST', handler: postfinanceCheckoutHandler, }); ``` ## Create Provider ```graphql mutation CreatePostFinanceProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.postfinance-checkout" } ) { _id } } mutation ConfigurePostFinanceProvider { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "completionMode", value: "Immediate" } ] } ) { _id } } ``` ## Configure PostFinance Checkout Webhooks Configure the [webhooks](https://checkout.postfinance.ch/space/select?target=/webhook/listener/list) in the PostFinance Checkout web interface for: - Accepted payments ("Verbuchung der Transaktion" β†’ "Erfolgreich") - Failed payments ("Verbuchung der Transaktion" β†’ "Fehlgeschlagen") ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `PFCHECKOUT_SPACE_ID` | - | PostFinance Checkout space ID (required) | | `PFCHECKOUT_USER_ID` | - | PostFinance API user ID (required) | | `PFCHECKOUT_SECRET` | - | PostFinance API secret (required) | | `PFCHECKOUT_WEBHOOK_PATH` | `/payment/postfinance-checkout` | Webhook endpoint path | | `PFCHECKOUT_SUCCESS_URL` | - | URL for successful payment redirect (appends `?order_id=`) | | `PFCHECKOUT_FAILED_URL` | - | URL for failed payment redirect (appends `?order_id=`) | ## Provider Configuration | Key | Description | |-----|-------------| | `completionMode` | `Immediate` (default) or `Deferred` for pre-authorization | # Usage To start a new PostFinance Checkout transaction, the mutation `signPaymentProviderForCheckout` is used. The plugin accepts two parameters in the `transactionContext` which control the behavior of the transaction: `integrationMode` and `completionMode`. ## Integration Mode You can specify the integration mode in the `transactionContext` of `signPaymentProviderForCheckout` with the parameter `integrationMode`. Valid values are `PaymentPage` (default value if nothing is specified), `Lightbox`, or `iFrame`: ```/*graphql*/ signPaymentProviderForCheckout( orderPaymentId: "order payment id of the cart you want to checkout", transactionContext: {integrationMode: "Lightbox"} ) ``` *To get the order payment id of the current active cart of the logged in user you can* ```/*graphql*/ me { cart { payment { _id } } } ``` The mutation returns a stringified JSON object with the transaction ID and the location: ```json { "transactionId": 424242, "location": "https://checkout.postfinance.ch/s/25563/payment/transaction/pay/424242?securityToken=" } ``` Depending on the `integrationMode`, `location` needs to be handled differently at the client side. For `PaymentPage` (as in the example above), it contains the URL that the user should be redirected to. For `Lightbox` and `iFrame`, it contains the JavaScript-URL, e.g. `https://checkout.postfinance.ch/assets/payment/lightbox-checkout-handler.js?spaceId=25563&transactionId=424242&securityToken=`. *Note that although the URL always follows the same schema (and therefore could be constructed from the space ID, transaction ID, and security token), it is fetched from a PostFinance API endpoint and the schema could in theory change.* After the successful payment, the web hook will be called and the order will be marked as paid. If the web hook was not called for some reason or a different error happened during the processing, you can also manually call `checkoutCart` and the system will check if the transaction was paid: ```/*graphql*/ checkoutCart( orderId: "order id from query parameter" ) { _id, status } ``` This gives Unchained Engine a (second) chance to process and settle the payment. ## Completion Mode The completion mode that is configured when instantiating a provider (see above for details) determines if transactions are completed immediately (default behavior if nothing is specified explicitly, value `Immediate`) or if only a reservation is created (`Deferred`) that can be voided / completed later. *Note that not all payment methods support deferred settlements. Alternatively, you can also use refunds.* When you use deferred completion, it's your responsibility to confirm the order (e.g., in an ERP system that handles the payment flows). ## Cancellation / Refunds An order payment can be cancelled in two cases: 1. A transaction was started with deferred settlement (i.e., only a reservation was created) and it should not be completed. 2. A transaction completed successfully, but there should be a refund to the user for some reason. In both cases, this is initiated when the order is rejected via `rejectOrder`. ## Saved Payment Methods The tokenization mode is set to `ALLOW_ONE_CLICK_PAYMENT` and the Unchained customer ID is passed to the PostFinance API. This gives the user the option to save a payment method. When he does this and orders for a second time, the saved method can be directly selected. # Testing For testing purposes, you can create a dedicated space in the PostFinance Checkout web interface and set it to testing mode. In this mode, transactions can be paid with test payment methods that appear in the web interface. `PFCHECKOUT_SPACE_ID` needs to be set to the id of this space (for unit tests or when running end user tests on a dev / staging environment). ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.payment.postfinance-checkout` | | Type | `GENERIC` | | Source | [payment/postfinance-checkout/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/postfinance-checkout/) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide - [Checkout Implementation](../../guides/checkout-implementation.md) - Complete checkout flow --- ## Saferpay Unchained payment plugin for Worldline Saferpay, supporting the Payment Page API for various payment methods. - [Saferpay JSON API Documentation](https://saferpay.github.io/jsonapi/) - [Saferpay Integration Guide](https://docs.saferpay.com/home/integration-guide/introduction) ## Installation **Express:** ```typescript const { SAFERPAY_WEBHOOK_PATH = '/payment/saferpay/webhook' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { saferpayTransactions: saferpayTransactionsModule, }, }); // Note: Saferpay uses GET method with query parameters, no body parsing needed app.get(SAFERPAY_WEBHOOK_PATH, saferpayHandler); ``` **Fastify:** ```typescript const { SAFERPAY_WEBHOOK_PATH = '/payment/saferpay/webhook' } = process.env; // Add module to platform options const unchainedApi = await startPlatform({ modules: { saferpayTransactions: saferpayTransactionsModule, }, }); // Note: Saferpay uses GET method with query parameters fastify.route({ url: SAFERPAY_WEBHOOK_PATH, method: 'GET', handler: saferpayHandler, }); ``` ## Create Provider ```graphql mutation CreateSaferpayProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.saferpay" } ) { _id } } mutation ConfigureSaferpayProvider { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "terminalId", value: "your-terminal-id" } ] } ) { _id } } ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `SAFERPAY_BASE_URL` | `https://test.saferpay.com/api` | API base URL. Production: `https://www.saferpay.com/api` | | `SAFERPAY_CUSTOMER_ID` | - | Your Saferpay customer ID (required) | | `SAFERPAY_USER` | - | API username (required) | | `SAFERPAY_PW` | - | API password (required) | | `SAFERPAY_WEBHOOK_PATH` | `/payment/saferpay/webhook` | Webhook endpoint path | | `SAFERPAY_RETURN_PATH` | `/saferpay/return` | User return URL path after payment | | `ROOT_URL` | `http://localhost:4010` | Base URL for webhook notifications | | `EMAIL_WEBSITE_URL` | - | Base URL for user redirects (falls back to ROOT_URL) | ## Provider Configuration | Key | Description | |-----|-------------| | `terminalId` | Your Saferpay terminal ID (required) | ## Payment Flow ### 1. Sign for Checkout Initialize a Saferpay Payment Page: ```graphql mutation SignPayment { signPaymentProviderForCheckout( orderPaymentId: "order-payment-id" transactionContext: { description: "Order Payment" } ) } ``` Returns a JSON string: ```json { "location": "https://test.saferpay.com/vt2/api/PaymentPage/...", "token": "saferpay-token", "transactionId": "hex-transaction-id" } ``` ### 2. Redirect to Payment Page ```typescript const result = JSON.parse(signResult); window.location.href = result.location; ``` ### 3. Handle Return After payment, users are redirected to: ``` EMAIL_WEBSITE_URL + SAFERPAY_RETURN_PATH?transactionId= ``` ### 4. Complete Checkout ```graphql mutation CheckoutWithSaferpay { checkoutCart( paymentContext: { transactionId: "hex-transaction-id" } ) { _id status orderNumber } } ``` ## Webhook Notifications The webhook receives success notifications automatically: ``` ROOT_URL/payment/saferpay/webhook?orderPaymentId=&signature=&transactionId= ``` The signature is verified server-side for security. ## Payment States | Saferpay Status | Description | Action | |-----------------|-------------|--------| | `AUTHORIZED` | Payment authorized | Ready for capture | | `CAPTURED` | Payment captured | Order complete | ## Confirm and Cancel ### Capture Payment After successful checkout with `AUTHORIZED` status: ```graphql mutation ConfirmOrder { confirmOrder(orderId: "order-id") { _id status } } ``` ### Cancel Authorization Before capture: ```graphql mutation CancelOrder { rejectOrder(orderId: "order-id") { _id status } } ``` ## Multiple Terminals Create multiple providers with different terminal IDs: ```graphql mutation CreateCHFTerminal { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.saferpay" } ) { _id } } mutation ConfigureCHFTerminal { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "terminalId", value: "chf-terminal-id" } ] } ) { _id } } ``` ## Transaction Context Options Available options in `transactionContext`: | Key | Description | |-----|-------------| | `description` | Payment description shown to user | | `Payment` | Override payment details | | `ReturnUrl` | Override return URL | ## Testing Use test credentials: - Set `SAFERPAY_BASE_URL=https://test.saferpay.com/api` - Use Saferpay test credentials - Test cards available in Saferpay documentation ## Features - Payment Page API integration - Multiple terminal support - Authorization with deferred capture - Signature-verified webhooks - Cancellation support ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.payment.saferpay` | | Type | `GENERIC` | | Version | `1.38.0` | | Source | [payment/saferpay/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/saferpay/) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide - [Checkout Implementation](../../guides/checkout-implementation.md) - Complete checkout flow --- ## Stripe Unchained payment plugin for Stripe, supporting payment intents, saved payment methods, and comprehensive payment processing with SCA compliance. - [Stripe API Documentation](https://stripe.com/docs/api) - [Stripe Payment Intents](https://stripe.com/docs/payments/payment-intents) - [Stripe Setup Intents](https://stripe.com/docs/payments/setup-intents) - [Stripe Webhooks](https://stripe.com/docs/webhooks) ## Installation **Express:** ```typescript const { STRIPE_WEBHOOK_PATH = '/payment/stripe' } = process.env; // IMPORTANT: Use raw body for Stripe signature verification app.use(STRIPE_WEBHOOK_PATH, express.raw({ type: 'application/json' }), stripeHandler); ``` **Fastify:** ```typescript const { STRIPE_WEBHOOK_PATH = '/payment/stripe' } = process.env; fastify.register((s, opts, registered) => { s.addContentTypeParser( 'application/json', { parseAs: 'string', bodyLimit: 1024 * 1024 }, s.defaultTextParser, ); s.route({ url: STRIPE_WEBHOOK_PATH, method: 'POST', handler: stripeHandler, }); registered(); }); ``` Requires the `stripe` npm package as a peer dependency: ```bash npm install stripe ``` ## Create Provider ```graphql mutation CreateStripeProvider { createPaymentProvider( paymentProvider: { type: GENERIC adapterKey: "shop.unchained.payment.stripe" } ) { _id } } mutation ConfigureStripeProvider { updatePaymentProvider( paymentProviderId: "provider-id" paymentProvider: { configuration: [ { key: "descriptorPrefix", value: "MYSHOP" } ] } ) { _id } } ``` ## Configure Stripe Dashboard 1. Go to **Developers** > **Webhooks** 2. Add endpoint: `https://your-domain.com/payment/stripe` 3. Select events: - `payment_intent.succeeded` - `setup_intent.succeeded` 4. Copy the signing secret to `STRIPE_ENDPOINT_SECRET` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `STRIPE_SECRET` | - | Your Stripe secret key (required) | | `STRIPE_ENDPOINT_SECRET` | - | Webhook endpoint secret for signature verification | | `STRIPE_WEBHOOK_PATH` | `/payment/stripe` | Webhook endpoint path | | `STRIPE_WEBHOOK_ENVIRONMENT` | - | Environment tag for filtering webhooks (optional) | | `EMAIL_WEBSITE_NAME` | `Unchained` | Description shown on payment intents | ## Provider Configuration | Key | Description | |-----|-------------| | `descriptorPrefix` | Custom prefix for statement descriptors (optional) | ## Payment Flow ### 1. Sign for Checkout Create a payment intent: ```graphql mutation SignPayment { signPaymentProviderForCheckout(orderPaymentId: "order-payment-id") } ``` Returns a client secret for the payment intent. ### 2. Collect Payment (Frontend) Use Stripe.js to collect payment: ```typescript const stripe = await loadStripe('pk_test_...'); const clientSecret = signResult; // From step 1 const { error, paymentIntent } = await stripe.confirmPayment({ clientSecret, confirmParams: { return_url: 'https://shop.example.com/checkout/complete', }, }); if (error) { console.error(error.message); } else if (paymentIntent.status === 'succeeded') { // Payment successful } ``` ### 3. Complete via Webhook (Recommended) The webhook automatically completes checkout when payment succeeds: ```typescript // Webhook handler calls internally: await services.orders.checkoutOrder(orderId, { paymentContext: { paymentIntentId: 'pi_...' } }); ``` ### 4. Manual Checkout (Alternative) Complete checkout with the payment intent ID: ```graphql mutation CheckoutWithStripe { checkoutCart( paymentContext: { paymentIntentId: "pi_stripe_payment_intent_id" } ) { _id status orderNumber } } ``` ## Saved Payment Methods ### Register a Payment Method 1. Create a setup intent: ```graphql mutation SignForRegistration { signPaymentProviderForCredentialRegistration( paymentProviderId: "stripe-provider-id" ) } ``` 2. Collect payment method with Stripe.js: ```typescript const { error, setupIntent } = await stripe.confirmSetup({ clientSecret: signResult, confirmParams: { return_url: 'https://shop.example.com/account/payment-methods', }, }); ``` 3. The webhook automatically registers the payment method, or register manually: ```graphql mutation RegisterPaymentMethod { registerPaymentCredentials( paymentProviderId: "stripe-provider-id" transactionContext: { setupIntentId: "seti_stripe_setup_intent_id" } ) { _id } } ``` ### Use Saved Payment Method ```graphql mutation CheckoutWithSaved { checkoutCart( paymentContext: { paymentCredentials: { token: "pm_stripe_payment_method_id" meta: { customer: "cus_stripe_customer_id" payment_method_types: ["card"] } } } ) { _id status } } ``` ## Environment Filtering Use `STRIPE_WEBHOOK_ENVIRONMENT` to filter webhooks in multi-environment setups: ```bash # Production STRIPE_WEBHOOK_ENVIRONMENT=production # Staging STRIPE_WEBHOOK_ENVIRONMENT=staging ``` The environment is stored in payment intent metadata and verified in webhook processing. ## Customer Management The plugin automatically creates and manages Stripe customers: - Customers are created/updated on first payment - Customer ID is stored in payment intent metadata - Customer search uses `metadata["userId"]` for deduplication ## Webhook Events | Event | Action | |-------|--------| | `payment_intent.succeeded` | Complete checkout | | `setup_intent.succeeded` | Register payment credentials | ## Testing ### Stripe CLI ```bash # Install Stripe CLI brew install stripe/stripe-cli/stripe # Login stripe login --api-key sk_test_... # Forward webhooks to local server stripe listen --forward-to http://localhost:4010/payment/stripe # Trigger test events stripe trigger payment_intent.succeeded ``` ### Test Cards | Card Number | Result | |-------------|--------| | `4242424242424242` | Succeeds | | `4000000000000002` | Declined | | `4000002500003155` | Requires 3D Secure | ## Validation The plugin validates: - Amount matches order total - Currency matches order currency - `orderPaymentId` in metadata matches the order payment ## Features - Payment Intents API with SCA compliance - Saved payment methods via Setup Intents - Automatic customer management - Statement descriptor customization - Multi-environment webhook support - Off-session payments ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.payment.stripe` | | Type | `GENERIC` | | Version | `2.0.0` | | Source | [payment/stripe/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/payment/stripe/) | ## Related - [Plugins Overview](./) - All available plugins - [Payment Integration Guide](../../guides/payment-integration.md) - Payment setup guide - [Checkout Implementation](../../guides/checkout-implementation.md) - Complete checkout flow --- ## Pricing Plugins Pricing plugins calculate prices at different levels of the order. They run in a chain based on their `orderIndex`, with lower values running first. ## Product Pricing Calculate prices when products are queried or added to cart. | Adapter Key | Order | Description | When to Use | |-------------|-------|-------------|-------------| | [`shop.unchained.pricing.product-price`](./pricing-product-catalog-price.md) | 0 | Base catalog price | Always β€” provides the base price from the product catalog | | [`shop.unchained.pricing.product-price-options`](./pricing-product-catalog-price-options.md) | 1 | Add-on option prices | When products have configurable options with price modifiers | | [`shop.unchained.pricing.rate-conversion`](./pricing-product-rate-conversion.md) | 10 | Currency conversion | When selling in multiple currencies | | [`shop.unchained.pricing.product-discount`](./pricing-product-discount.md) | 30 | Apply discounts | When using product-level discount rules or coupons | | [`shop.unchained.pricing.product-swiss-tax`](./pricing-product-swiss-tax.md) | 80 | Swiss VAT | Swiss shops requiring 8.1% / 2.6% VAT calculation | | [`shop.unchained.pricing.product-round`](./pricing-product-round.md) | 90 | Round prices | When prices must be rounded to 0.05 (Swiss rounding) | ## Delivery Pricing Calculate shipping and handling fees. | Adapter Key | Order | Description | When to Use | |-------------|-------|-------------|-------------| | [`shop.unchained.pricing.delivery-free`](./pricing-delivery-free.md) | 0 | Zero-cost delivery | Default β€” sets delivery cost to zero, replace with custom adapter for fees | | [`shop.unchained.pricing.delivery-swiss-tax`](./pricing-delivery-swiss-tax.md) | 80 | Swiss VAT on delivery | Swiss shops requiring VAT on shipping fees | ## Payment Pricing Calculate payment processing fees. | Adapter Key | Order | Description | When to Use | |-------------|-------|-------------|-------------| | [`shop.unchained.pricing.payment-free`](./pricing-payment-free.md) | 0 | Zero-cost payment | Default β€” sets payment fee to zero, replace with custom adapter for surcharges | ## Order Pricing Aggregate prices into order totals. | Adapter Key | Order | Description | When to Use | |-------------|-------|-------------|-------------| | [`shop.unchained.pricing.order-items`](./pricing-order-items.md) | 0 | Sum product totals | Always β€” aggregates product line items into order total | | [`shop.unchained.pricing.order-delivery`](./pricing-order-delivery.md) | 10 | Add delivery fees | Always β€” adds delivery costs to order total | | [`shop.unchained.pricing.order-payment`](./pricing-order-payment.md) | 10 | Add payment fees | Always β€” adds payment surcharges to order total | | [`shop.unchained.pricing.order-items-discount`](./pricing-order-items-discount.md) | 30 | Items-only discounts | When applying discounts that only affect product line items | | [`shop.unchained.pricing.order-discount`](./pricing-order-discount.md) | 40 | Full order discounts | When applying discounts across the entire order total | | [`shop.unchained.pricing.order-round`](./pricing-order-round.md) | 90 | Round order totals | When final order totals need rounding (Swiss 0.05 rounding) | ## Discount Adapters Define discount rules and coupon codes. | Adapter Key | Description | |-------------|-------------| | [`shop.unchained.discount.100-off`](./pricing-discount-100-off.md) | 100 CHF off coupon | | [`shop.unchained.discount.half-price`](./pricing-discount-half-price.md) | Auto 50% for tagged users | | [`shop.unchained.discount.half-price-manual`](./pricing-discount-half-price-manual.md) | 50% off coupon | ## Creating Custom Pricing Plugins See [Product Pricing](../../extend/pricing/product-pricing.md), [Delivery Pricing](../../extend/pricing/delivery-pricing.md), [Payment Pricing](../../extend/pricing/payment-pricing.md), and [Order Discounts](../../extend/pricing/order-discounts.md) for creating custom pricing adapters. --- ## Free Delivery Pricing A simple delivery pricing adapter that sets delivery fees to zero. Use as a starting point or for delivery methods that don't charge shipping. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works Adds a delivery fee of 0 to the calculation, with no tax implications. ## Use Cases - Digital products / downloads - Local pickup - Free shipping promotions (when combined with conditional logic) - Development and testing ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.delivery-free` | | Version | `1.0.0` | | Order Index | `0` | | Source | [pricing/free-delivery.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/free-delivery.ts) | ## Related - [Delivery Swiss Tax](./pricing-delivery-swiss-tax.md) - Add Swiss VAT to delivery - [Delivery Pricing](../../extend/pricing/delivery-pricing.md) - Custom delivery pricing --- ## Delivery Swiss Tax Applies Swiss VAT rates to delivery fees. Only activates for orders with delivery addresses in Switzerland (CH) or Liechtenstein (LI). ## Installation ```typescript ``` ## How It Works 1. Checks if the order has a delivery and address in CH/LI 2. Resolves the tax category from delivery provider configuration 3. Falls back to DEFAULT rate (8.1%) if not specified 4. Calculates and adds tax to the delivery fee ## Tax Categories | Category | Rate (2024+) | Rate (pre-2024) | |----------|--------------|-----------------| | DEFAULT | 8.1% | 7.7% | | REDUCED | 2.6% | 2.5% | | SPECIAL | 3.8% | 3.7% | ## Configuration Set the tax category on the delivery provider: ```graphql mutation CreateDeliveryProvider { createDeliveryProvider( deliveryProvider: { type: SHIPPING adapterKey: "shop.unchained.post" } ) { _id } } mutation ConfigureDeliveryProvider { updateDeliveryProvider( deliveryProviderId: "provider-id" deliveryProvider: { configuration: [ { key: "swiss-tax-category", value: "default" } ] } ) { _id } } ``` ## Activation Conditions The adapter only activates when: - The order exists - Order delivery is set - Delivery address is in Switzerland or Liechtenstein ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.delivery-swiss-tax` | | Version | `1.0.0` | | Order Index | `80` | | Source | [pricing/delivery-swiss-tax.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/delivery-swiss-tax.ts) | ## Related - [Product Swiss Tax](./pricing-product-swiss-tax.md) - Swiss VAT for products - [Free Delivery](./pricing-delivery-free.md) - Zero-cost delivery - [Delivery Pricing](../../extend/pricing/delivery-pricing.md) - Custom delivery pricing --- ## Discount 100 Off A sample discount adapter demonstrating a fixed-amount coupon code (100 CHF off). Use this as a template for implementing your own coupon systems. ## Installation ```typescript ``` ## How It Works 1. User enters coupon code `100OFF` 2. Adapter validates the code 3. Returns a fixed discount of 100.00 (10000 cents) to be applied by [Order Discount](./pricing-order-discount.md) ## Coupon Code | Code | Effect | |------|--------| | `100OFF` | 100.00 off total order (case-insensitive) | ## Usage Apply the discount: ```graphql mutation ApplyDiscount { addCartDiscount(code: "100OFF") { _id total { amount currencyCode } } } ``` Remove the discount: ```graphql mutation RemoveDiscount { removeCartDiscount(discountId: "discount-id") { _id } } ``` ## Configuration The adapter targets `shop.unchained.pricing.order-discount`: ```typescript discountForPricingAdapterKey: ({ pricingAdapterKey }) => { if (pricingAdapterKey === 'shop.unchained.pricing.order-discount') { return { fixedRate: 10000 }; // 100.00 in cents } return null; }, ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.discount.100-off` | | Version | `1.0.0` | | Order Index | `10` | | Manual Addition | Yes | | Manual Removal | Yes | | System Triggering | No | | Source | [pricing/discount-100-off.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/discount-100-off.ts) | ## Related - [Discount Half Price](./pricing-discount-half-price.md) - Percentage discount - [Order Discount](./pricing-order-discount.md) - Order-level discount pricing - [Order Discounts](../../extend/pricing/order-discounts.md) - Creating custom discounts --- ## Discount Half Price Manual A sample discount adapter demonstrating a percentage-based coupon code. Applies 50% off to all products when code is entered. ## Installation ```typescript ``` ## How It Works 1. User enters coupon code `HALFPRICE` 2. Adapter validates the code 3. Returns 50% discount to be applied by [Product Discount](./pricing-product-discount.md) ## Coupon Code | Code | Effect | |------|--------| | `HALFPRICE` | 50% off all products (case-sensitive) | ## Usage Apply the discount: ```graphql mutation ApplyDiscount { addCartDiscount(code: "HALFPRICE") { _id total { amount currencyCode } } } ``` ## Difference from Half Price | Adapter | Trigger | Removal | |---------|---------|---------| | Half Price | Automatic (user tag) | Not removable | | Half Price Manual | Coupon code | User can remove | ## Configuration The adapter targets `shop.unchained.pricing.product-discount`: ```typescript discountForPricingAdapterKey({ pricingAdapterKey }) { if (pricingAdapterKey === 'shop.unchained.pricing.product-discount') { return { rate: 0.5 }; // 50% off } return null; }, ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.discount.half-price-manual` | | Version | `1.0.0` | | Order Index | `10` | | Manual Addition | Yes | | Manual Removal | Yes | | System Triggering | No | | Source | [pricing/discount-half-price-manual.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/discount-half-price-manual.ts) | ## Related - [Discount Half Price](./pricing-discount-half-price.md) - Automatic version - [Discount 100 Off](./pricing-discount-100-off.md) - Fixed amount discount - [Order Discounts](../../extend/pricing/order-discounts.md) - Creating custom discounts --- ## Discount Half Price A sample discount adapter demonstrating automatic system-triggered discounts. Applies 50% off to users with the `half-price` tag. ## Installation ```typescript ``` ## How It Works 1. System automatically checks all orders during pricing 2. Looks for users with the `half-price` tag 3. If found, applies 50% off to all products via [Product Discount](./pricing-product-discount.md) ## Eligibility Users must have the `half-price` tag: ```graphql mutation TagUser { setUserTags( userId: "user-id" tags: ["half-price"] ) { _id tags } } ``` ## Configuration The adapter targets `shop.unchained.pricing.product-discount`: ```typescript discountForPricingAdapterKey({ pricingAdapterKey }) { if (pricingAdapterKey === 'shop.unchained.pricing.product-discount') { return { rate: 0.5 }; // 50% off } return null; }, ``` ## Use Cases - Employee discounts - VIP customer pricing - Partner/affiliate discounts - Loyalty rewards ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.discount.half-price` | | Version | `1.0.0` | | Order Index | `10` | | Manual Addition | No | | Manual Removal | No | | System Triggering | Yes (user tag check) | | Source | [pricing/discount-half-price.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/discount-half-price.ts) | ## Related - [Discount Half Price Manual](./pricing-discount-half-price-manual.md) - Code-based version - [Discount 100 Off](./pricing-discount-100-off.md) - Fixed amount discount - [Product Discount](./pricing-product-discount.md) - Product-level discounts --- ## Order Delivery Pricing Adds the calculated delivery fees and taxes to the order total. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works 1. Checks if order has a delivery method set 2. Creates a DeliveryPricingSheet from the delivery calculation 3. Extracts gross price and tax sum 4. Adds delivery amount and tax to the order pricing sheet ## Prerequisites - Delivery must be selected on the order - Delivery pricing adapters must have calculated the delivery fees ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.order-delivery` | | Version | `1.0.0` | | Order Index | `10` | | Source | [pricing/order-delivery.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/order-delivery.ts) | ## Related - [Order Items](./pricing-order-items.md) - Sum product prices - [Order Payment](./pricing-order-payment.md) - Add payment fees - [Delivery Pricing](../../extend/pricing/delivery-pricing.md) - Custom delivery pricing --- ## Order Discount Applies discounts to the total order value, including items, delivery, and payment fees. This is the most comprehensive discount type. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works 1. Calculates totals for items, delivery, and payment 2. Determines proportional shares for each category 3. For each discount: - First applies to items (up to items total) - Remaining discount applies to delivery and payment 4. Distributes discounts proportionally for correct tax calculation 5. Adds discount and tax adjustments to the order pricing ## Discount Distribution Example ``` Items Total: 80 CHF Delivery: 15 CHF Payment: 5 CHF Order Total: 100 CHF 10% Discount (10 CHF): - Items: 8 CHF - Delivery: 1.50 CHF - Payment: 0.50 CHF ``` ## Fixed Rate Handling For fixed-rate discounts that exceed the items total: ``` Items Total: 50 CHF Delivery: 20 CHF Fixed Discount: 60 CHF Result: - Items discount: 50 CHF (full amount) - Delivery discount: 10 CHF (remaining) ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.order-discount` | | Version | `1.0.0` | | Order Index | `40` | | Source | [pricing/order-discount.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/order-discount.ts) | ## Related - [Order Items Discount](./pricing-order-items-discount.md) - Items-only discounts - [Discount 100 Off](./pricing-discount-100-off.md) - Example fixed discount - [Order Discounts](../../extend/pricing/order-discounts.md) - Creating custom discounts --- ## Order Items Discount Applies discounts to the total value of goods (items only), excluding delivery and payment fees. Use this for discounts that should only affect product prices. ## Installation ```typescript ``` ## How It Works 1. Calculates the total amount of all order items 2. Determines each item's share of the total (for proportional tax calculation) 3. For each discount, calculates the discount amount 4. Distributes the discount proportionally across items 5. Adds discount and tax adjustments to the order pricing ## Discount Distribution Discounts are distributed proportionally across items to maintain correct tax calculations: ``` Item A: 60 CHF (60% of total) Item B: 40 CHF (40% of total) Discount: 10 CHF Item A discount: 6 CHF Item B discount: 4 CHF ``` ## Difference from Order Discount | Adapter | Applies To | |---------|------------| | Order Items Discount | Products only | | Order Discount | Products + Delivery + Payment | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.order-items-discount` | | Version | `1.0.0` | | Order Index | `30` | | Source | [pricing/order-items-discount.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/order-items-discount.ts) | ## Related - [Order Discount](./pricing-order-discount.md) - Discounts on full order - [Product Discount](./pricing-product-discount.md) - Product-level discounts - [Order Discounts](../../extend/pricing/order-discounts.md) - Creating custom discounts --- ## Order Items Pricing Sums up all product item prices and taxes from order positions into the order total. This is the foundation of order-level pricing. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works 1. Iterates through all order positions 2. For each position, creates a ProductPricingSheet from the calculation 3. Sums the gross price and tax amounts 4. Adds the totals to the order pricing sheet ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.order-items` | | Version | `1.0.0` | | Order Index | `0` | | Source | [pricing/order-items.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/order-items.ts) | ## Related - [Order Delivery](./pricing-order-delivery.md) - Add delivery fees to order - [Order Payment](./pricing-order-payment.md) - Add payment fees to order - [Order Discount](./pricing-order-discount.md) - Apply order-level discounts --- ## Order Payment Pricing Adds the calculated payment fees and taxes to the order total. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works 1. Checks if order has a payment method set 2. Creates a PaymentPricingSheet from the payment calculation 3. Extracts gross price and tax sum 4. Adds payment amount and tax to the order pricing sheet ## Prerequisites - Payment must be selected on the order - Payment pricing adapters must have calculated the payment fees ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.order-payment` | | Version | `1.0.0` | | Order Index | `10` | | Source | [pricing/order-payment.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/order-payment.ts) | ## Related - [Order Items](./pricing-order-items.md) - Sum product prices - [Order Delivery](./pricing-order-delivery.md) - Add delivery fees - [Payment Pricing](../../extend/pricing/payment-pricing.md) - Custom payment pricing --- ## Order Price Rounding Rounds all order pricing categories (items, delivery, payment, discounts, taxes) to a configurable precision. Typically runs last in the order pricing chain. ## Installation ```typescript ``` ## How It Works 1. Calculates the rounding difference for each category: - Items - Delivery - Payment - Discounts - Taxes 2. Adds adjustment entries to round each category 3. Maintains tax proportions when rounding ## Configuration Configure the rounding behavior before starting the engine: ```typescript // Round to nearest 5 cents (default) OrderPriceRound.configure({ defaultPrecision: 5, }); // Round to nearest 10 cents OrderPriceRound.configure({ defaultPrecision: 10, }); // Disable rounding OrderPriceRound.configure({ defaultPrecision: 0, }); // Custom rounding function OrderPriceRound.configure({ defaultPrecision: 5, roundTo: (value, precision, currencyCode) => { if (precision === 0) return value; return Math.round(value / precision) * precision; }, }); ``` ## Default Settings | Setting | Default | Description | |---------|---------|-------------| | `defaultPrecision` | `5` | Round to nearest 5 cents | | `roundTo` | Standard rounding | Returns 0 if precision is 0 | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.order-round` | | Version | `1.0.0` | | Order Index | `90` | | Source | [pricing/order-round.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/order-round.ts) | ## Related - [Product Price Rounding](./pricing-product-round.md) - Round product prices - [Pricing System](../../concepts/pricing-system.md) - Pricing overview --- ## Free Payment Pricing A simple payment pricing adapter that sets payment fees to zero. Use as a starting point or for payment methods without processing fees. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works Adds a payment fee of 0 to the calculation, with no tax implications. ## Use Cases - Invoice payments (no processing fee) - Bank transfers - Cash on delivery - Development and testing ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.payment-free` | | Version | `1.0.0` | | Order Index | `0` | | Source | [pricing/free-payment.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/free-payment.ts) | ## Related - [Payment Pricing](../../extend/pricing/payment-pricing.md) - Custom payment pricing - [Invoice Plugin](../payment/invoice.md) - Invoice payment provider --- ## Product Catalog Price Options Adds prices for product options to the pricing calculation. Used when products have configurable options that affect pricing. ## Installation ```typescript ``` ## How It Works 1. Reads the `configuration` array from the pricing context 2. Finds all entries with `key: 'option'` 3. Looks up each option product by its ID 4. Adds each option's price to the total ## Configuration Format Options are passed via the cart item configuration: ```typescript const configuration = [ { key: 'option', value: 'product-id-of-option-1' }, { key: 'option', value: 'product-id-of-option-2' }, ]; ``` ## Use Cases - Add-on products (e.g., gift wrapping, extended warranty) - Product customizations with price impact - Configurable bundles ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.product-price-options` | | Version | `1.0` | | Order Index | `1` | | Source | [pricing/product-catalog-price-options.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/product-catalog-price-options.ts) | ## Related - [Product Catalog Price](./pricing-product-catalog-price.md) - Base product pricing - [Product Discount](./pricing-product-discount.md) - Apply discounts --- ## Product Catalog Price Adds the gross price from the product catalog to the pricing calculation. This is typically the first adapter in the product pricing chain. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works 1. Looks up the product price for the given country, currency, and quantity 2. Adds the item total (price Γ— quantity) to the calculation 3. For bundle products, calculates prices from bundled product prices if no direct price exists ## Bundle Product Support If a product is a `BUNDLE_PRODUCT` and has no direct price configured, the adapter iterates through `product.bundleItems` and sums up the prices of all bundled products. ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.product-price` | | Version | `1.0.0` | | Order Index | `0` | | Source | [pricing/product-catalog-price.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/product-catalog-price.ts) | ## Related - [Product Catalog Price Options](./pricing-product-catalog-price-options.md) - Add prices for product options - [Product Discount](./pricing-product-discount.md) - Apply discounts to product prices - [Product Swiss Tax](./pricing-product-swiss-tax.md) - Apply Swiss VAT --- ## Product Discount Applies discounts to product-level pricing. Works in conjunction with discount adapters that provide discount configurations. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## How It Works 1. Iterates through all active discounts on the order 2. For each discount, resolves the configuration (supports custom resolvers) 3. Calculates the discount amount based on the item total 4. Adds discount and tax adjustments to the calculation ## Discount Configuration Discounts can specify: | Property | Description | |----------|-------------| | `rate` | Percentage discount (0.1 = 10%) | | `fixedRate` | Fixed amount in cents | | `isNetPrice` | Whether amount is net (before tax) | | `taxRate` | Specific tax rate to apply | ## Custom Price Configuration Resolver Discounts can provide a `customPriceConfigurationResolver` function for dynamic discount logic: ```typescript const configuration = { rate: 0.1, customPriceConfigurationResolver: (product, quantity, config) => { // Custom logic based on product, quantity, or configuration if (product.tags?.includes('sale')) { return { rate: 0.2 }; // 20% off sale items } return { rate: 0.1 }; // 10% off regular items }, }; ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.product-discount` | | Version | `1.0.0` | | Order Index | `30` | | Source | [pricing/product-discount.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/product-discount.ts) | ## Related - [Discount Half Price](./pricing-discount-half-price.md) - Example discount adapter - [Discount 100 Off](./pricing-discount-100-off.md) - Fixed amount discount - [Order Discounts](../../extend/pricing/order-discounts.md) - Creating custom discounts --- ## Product Rate Conversion Converts product prices between currencies using configured exchange rates. Only activates when no direct price exists for the target currency. ## Installation ```typescript ``` ## How It Works 1. Checks if a price already exists in the calculation (skips if yes) 2. Looks up a price in any available currency for the product 3. Fetches the exchange rate between source and target currencies 4. Converts and adds the price to the calculation ## Prerequisites - Exchange rates must be configured in the database - Both source and target currencies must be active ## Setting Exchange Rates Exchange rates are managed through the `products.prices.updateRates` module method. Use one of the built-in worker plugins to update rates automatically: - [Update ECB Rates](../workers/worker-update-ecb-rates.md) - European Central Bank rates - [Update Coinbase Rates](../workers/worker-update-coinbase-rates.md) - Cryptocurrency rates Or update rates programmatically: ```typescript await unchainedAPI.modules.products.prices.updateRates([ { baseCurrency: 'CHF', quoteCurrency: 'EUR', rate: 0.95, timestamp: new Date(), }, ]); ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.rate-conversion` | | Version | `1.0.0` | | Order Index | `10` | | Source | [pricing/product-price-rateconversion.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/product-price-rateconversion.ts) | ## Related - [Product Catalog Price](./pricing-product-catalog-price.md) - Base product pricing - [Multi-Currency Setup](../../guides/multi-currency-setup.md) - Currency configuration guide --- ## Product Price Rounding Rounds all product pricing calculations to a configurable precision. Typically runs last in the product pricing chain. ## Installation ```typescript ``` ## How It Works 1. Takes all existing calculation items 2. Rounds each amount to the configured precision 3. Replaces the original calculation with rounded values ## Configuration Configure the rounding behavior before starting the engine: ```typescript // Round to nearest 5 cents (default) ProductRound.configure({ defaultPrecision: 5, }); // Round to nearest 10 cents ProductRound.configure({ defaultPrecision: 10, }); // Custom rounding function (e.g., always round up) ProductRound.configure({ defaultPrecision: 5, roundTo: (value, precision, currencyCode) => { return Math.ceil(value / precision) * precision; }, }); ``` ## Default Settings | Setting | Default | Description | |---------|---------|-------------| | `defaultPrecision` | `5` | Round to nearest 5 cents | | `roundTo` | Standard rounding | `Math.round(value / precision) * precision` | ## Currency-Specific Rounding The `roundTo` function receives the currency code, enabling currency-specific logic: ```typescript ProductRound.configure({ defaultPrecision: 5, roundTo: (value, precision, currencyCode) => { if (currencyCode === 'JPY') { // Japanese Yen has no decimal places return Math.round(value); } return Math.round(value / precision) * precision; }, }); ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.product-round` | | Version | `1.0.0` | | Order Index | `90` | | Source | [pricing/product-round.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/product-round.ts) | ## Related - [Order Price Rounding](./pricing-order-round.md) - Round order totals - [Product Pricing](../../extend/pricing/product-pricing.md) - Custom product pricing --- ## Product Swiss Tax Applies Swiss VAT rates to product prices. Only activates for deliveries to Switzerland (CH) or Liechtenstein (LI). ## Installation ```typescript ``` ## How It Works 1. Checks if the delivery address is in Switzerland or Liechtenstein 2. Determines the tax category from: - Product tags (e.g., `swiss-tax-category:reduced`) - Delivery provider configuration - Falls back to DEFAULT (8.1%) 3. Calculates and adds tax amounts to the pricing sheet ## Tax Categories | Category | Rate (2024+) | Rate (pre-2024) | Use Case | |----------|--------------|-----------------|----------| | DEFAULT | 8.1% | 7.7% | Standard goods and services | | REDUCED | 2.6% | 2.5% | Food, books, newspapers, medicines | | SPECIAL | 3.8% | 3.7% | Accommodation services | ## Configuration ### Via Product Tags Add a tag to the product: ``` swiss-tax-category:reduced swiss-tax-category:special ``` ### Via Delivery Provider Configure the delivery provider: ```graphql mutation CreateDeliveryProvider { createDeliveryProvider( deliveryProvider: { type: SHIPPING adapterKey: "shop.unchained.post" } ) { _id } } mutation ConfigureDeliveryProvider { updateDeliveryProvider( deliveryProviderId: "provider-id" deliveryProvider: { configuration: [ { key: "swiss-tax-category", value: "reduced" } ] } ) { _id } } ``` ## Net vs Gross Prices The adapter handles both net and gross prices: - **Net prices**: Tax is added on top (`amount * taxRate`) - **Gross prices**: Tax is extracted from the total (`amount - amount / (1 + taxRate)`) ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.pricing.product-swiss-tax` | | Version | `1.0.0` | | Order Index | `80` | | Source | [pricing/product-swiss-tax.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/pricing/product-swiss-tax.ts) | ## Related - [Delivery Swiss Tax](./pricing-delivery-swiss-tax.md) - Swiss VAT for delivery fees - [Product Pricing](../../extend/pricing/product-pricing.md) - Custom product pricing --- ## Quotation Plugins Quotation plugins handle the creation and management of price quotations and custom offerings for products. ## Available Plugins | Adapter Key | Description | Base Preset | |-------------|-------------|-------------| | [`shop.unchained.quotations.manual`](./quotation-manual.md) | Manual quotation handling with 1-hour expiry | Yes | ## How Quotations Work Quotations in Unchained allow customers to request custom prices for products. The flow is: 1. Customer requests a quotation for a product 2. The quotation adapter processes the request 3. A quote is generated with an expiration time 4. The customer can accept or reject the quote 5. Accepted quotes can be used to create orders ## Creating Custom Quotation Plugins See [Custom Quotation Plugins](../../extend/quotation.md) for creating your own quotation adapters. --- ## Manual Quotations A simple quotation adapter that creates quotations with a 1-hour expiration time. Ideal for manual price negotiation workflows. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Features - **Automatic Activation**: Activated for all products - **1-Hour Expiry**: Quotations expire after 1 hour by default - **Simple Implementation**: Minimal configuration required - **Manual Workflow**: Designed for human-reviewed quotations ## How It Works 1. Customer requests a quotation for a product 2. The adapter creates a quotation that expires in 1 hour 3. Admin reviews and can adjust the quotation 4. Customer accepts or the quotation expires ## Usage ### Request a Quotation ```graphql mutation RequestQuotation { requestQuotation( productId: "product-id" configuration: [ { key: "quantity", value: "100" } { key: "notes", value: "Bulk order for corporate event" } ] ) { _id status expires } } ``` ### Query Quotations ```graphql query MyQuotations { me { quotations { _id status product { texts { title } } expires quotationNumber } } } ``` ### Admin: Make Quotation Proposal ```graphql mutation MakeProposal { makeQuotationProposal( quotationId: "quotation-id" quotationContext: { price: 8999 currency: "CHF" } ) { _id status } } ``` ### Accept Quotation ```graphql mutation AcceptQuotation { verifyQuotation(quotationId: "quotation-id") { _id status } } ``` ## Quotation States | Status | Description | |--------|-------------| | `REQUESTED` | Customer has requested a quotation | | `PROCESSING` | Quotation is being processed | | `PROPOSED` | A price has been proposed | | `FULFILLED` | Quotation has been accepted and used | | `REJECTED` | Quotation was rejected or expired | ## Extending the Adapter For custom quotation logic: ```typescript const CustomQuotationAdapter: IQuotationAdapter = { ...QuotationAdapter, key: 'my-shop.quotations.custom', version: '1.0.0', label: 'Custom Quotations', orderIndex: 0, isActivatedFor: (product) => { // Only activate for specific products return product?.meta?.quotationEnabled === true; }, actions: (params) => { const { quotation, modules } = params; return { ...QuotationAdapter.actions(params), quote: async () => { // Custom logic to calculate quote const product = await modules.products.findProduct({ productId: quotation.productId, }); // Auto-calculate bulk discount const quantity = quotation.configuration?.find(c => c.key === 'quantity')?.value || 1; const basePrice = product?.commerce?.pricing?.[0]?.amount || 0; const discount = quantity > 100 ? 0.15 : quantity > 50 ? 0.10 : 0.05; return { expires: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours price: Math.round(basePrice * quantity * (1 - discount)), }; }, }; }, }; QuotationDirector.registerAdapter(CustomQuotationAdapter); ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.quotations.manual` | | Version | `1.0.0` | | Order Index | `0` | | Source | [quotations/manual.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/quotations/manual.ts) | ## Related - [Plugins Overview](./) - All available plugins - [Custom Quotation Plugins](../../extend/quotation.md) - Write your own --- ## Warehousing Plugins Warehousing plugins manage inventory, stock levels, and product fulfillment. | Adapter Key | Description | |-------------|-------------| | [`shop.unchained.warehousing.store`](./warehousing-store.md) | Basic inventory management | | [`shop.unchained.warehousing.infinite-minter`](./warehousing-eth-minter.md) | Ethereum NFT minting | ## Creating Custom Warehousing Plugins See [Custom Warehousing Plugins](../../extend/order-fulfilment/fulfilment-plugins/warehousing.md) for creating your own warehousing adapters. --- ## ETH Minter # ETH Minter Warehousing Adapter The ETH Minter adapter enables tokenization for NFT and Web3 products, supporting ERC-721 and ERC-1155 standards. ## Installation ```typescript ``` ## Configuration Create a warehousing provider for tokenized products: ```graphql mutation CreateETHMinter { createWarehousingProvider(warehousingProvider: { type: VIRTUAL adapterKey: "shop.unchained.warehousing.infinite-minter" }) { _id } } ``` Configure the `chainId` via the Admin UI after creation. ## Features - ERC-721 (non-fungible) token minting - ERC-1155 (semi-fungible) token minting - Supply tracking and enforcement - ERC metadata endpoint support - Multi-language token metadata ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.warehousing.infinite-minter` | | Type | `VIRTUAL` | | Order Index | `0` | | Source | [warehousing/eth-minter.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/warehousing/eth-minter.ts) | ## Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `MINTER_TOKEN_OFFSET` | Starting token ID offset | `0` | | `ROOT_URL` | Base URL for metadata endpoints | `http://localhost:4010` | ## Configuration Options | Key | Description | |-----|-------------| | `chainId` | Ethereum chain ID (1 = mainnet, 5 = goerli, etc.) | ## Product Setup Configure tokenized products: ```graphql mutation CreateTokenizedProduct { createProduct(product: { type: TOKENIZED_PRODUCT }) { _id } } ``` After creating the product, configure tokenization via the Admin UI or update the product with tokenization settings. Tokenization configuration includes: - `contractAddress` - The smart contract address - `contractStandard` - `ERC721` or `ERC1155` - `supply` - Maximum token supply - `ercMetadataProperties` - Metadata for the token ### Contract Standards **ERC-721 (Non-Fungible)** - Each token has a unique serial number - Serial number increments per mint - Quantity is always 1 per token **ERC-1155 (Semi-Fungible)** - Tokens share the same tokenId - Quantity can be > 1 - Requires `tokenId` in product configuration ``` # ERC-1155 product tokenization config tokenization: { contractAddress: "0x..." contractStandard: ERC1155 tokenId: "42" # Required for ERC-1155 supply: 1000 } ``` ## Behavior ### `isActive()` Returns `true` only for `TOKENIZED_PRODUCT` type products. ### `stock()` Returns remaining supply: ```typescript const tokensCreated = await getTokensCreated(); return supply ? supply - tokensCreated : 0; ``` ### `tokenize()` Creates token records when an order is fulfilled: **ERC-721:** ```typescript // Creates N unique tokens for N quantity [ { tokenSerialNumber: "1", quantity: 1 }, { tokenSerialNumber: "2", quantity: 1 }, // ... ] ``` **ERC-1155:** ```typescript // Creates one token record with total quantity [ { tokenSerialNumber: "42", quantity: 5 } ] ``` ### `tokenMetadata()` Returns ERC-compatible metadata JSON: ```json { "name": "Product Title #1", "description": "Product description", "image": "https://cdn.example.com/image.png", "properties": { "external_url": "https://example.com", "attributes": [ { "trait_type": "Rarity", "value": "Legendary" } ] }, "localization": { "uri": "https://example.com/erc-metadata/{id}/{locale}/{tokenId}.json", "default": "en", "locales": ["en", "de", "fr"] } } ``` ## Metadata Endpoint The adapter expects a metadata endpoint at: ``` {ROOT_URL}/erc-metadata/{productId}/{locale}/{tokenId}.json ``` Implement this endpoint in your server: ```typescript app.get('/erc-metadata/:productId/:locale/:tokenId.json', async (req, res) => { const { productId, locale, tokenId } = req.params; const metadata = await modules.warehousing.tokenMetadata({ productId, locale, tokenSerialNumber: tokenId, }); res.json(metadata); }); ``` ## On-Chain Integration The adapter creates database records. Actual blockchain minting requires additional integration: ```typescript // After order confirmation const tokens = await modules.warehousing.tokenize(orderPosition); for (const token of tokens) { // Mint on blockchain const tx = await nftContract.mint( customerWallet, token.tokenSerialNumber ); // Update token with transaction hash await modules.warehousing.updateToken(token._id, { 'meta.txHash': tx.hash, 'meta.mintedAt': new Date(), }); } ``` ## Custom Minting Adapter For custom blockchain integrations: ```typescript WarehousingDirector, WarehousingAdapter, type IWarehousingAdapter } from '@unchainedshop/core'; const CustomMinterAdapter: IWarehousingAdapter = { ...WarehousingAdapter, key: 'my-shop.custom-minter', label: 'Custom NFT Minter', version: '1.0.0', typeSupported: (type) => type === 'VIRTUAL', actions(configuration, context) { const { product, orderPosition } = context; return { ...WarehousingAdapter.actions(configuration, context), isActive() { return product?.type === 'TOKENIZED_PRODUCT'; }, configurationError() { if (!process.env.MINTER_PRIVATE_KEY) { return { code: 'MISSING_MINTER_KEY' }; } return null; }, async stock() { // Check on-chain supply const totalSupply = await contract.totalSupply(); const maxSupply = await contract.maxSupply(); return maxSupply - totalSupply; }, async tokenize() { const { contractAddress, tokenId } = product.tokenization; // Mint on-chain const tx = await contract.mint( orderPosition.quantity, { gasLimit: 500000 } ); const receipt = await tx.wait(); // Extract token IDs from events const mintEvents = receipt.events.filter(e => e.event === 'Transfer'); return mintEvents.map(event => ({ _id: generateDbObjectId(), tokenSerialNumber: event.args.tokenId.toString(), contractAddress, quantity: 1, meta: { txHash: tx.hash, blockNumber: receipt.blockNumber, }, })); }, }; }, }; WarehousingDirector.registerAdapter(CustomMinterAdapter); ``` ## Querying Tokens ```graphql query UserTokens { me { orders { items { product { _id texts { title } } tokens { _id quantity } } } } } ``` ## Related - [Plugins Overview](./) - All available plugins - [Store Adapter](./warehousing-store.md) - Physical inventory - [Custom Warehousing Plugins](../../extend/order-fulfilment/fulfilment-plugins/warehousing.md) - Write your own --- ## Store Warehousing # Store Warehousing Adapter The Store adapter provides basic physical inventory management for simple use cases. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Configuration Create a warehousing provider: ```graphql mutation CreateStoreWarehousing { createWarehousingProvider(warehousingProvider: { type: PHYSICAL adapterKey: "shop.unchained.warehousing.store" }) { _id } } ``` Configure the `name` via the Admin UI after creation. ## Features - Physical inventory management - Unlimited stock (returns 99999) - Zero production/commissioning time - Simple drop-in for development ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.warehousing.store` | | Type | `PHYSICAL` | | Order Index | `0` | | Default Stock | `99999` | | Source | [warehousing/store.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/warehousing/store.ts) | ## Configuration Options | Key | Description | Default | |-----|-------------|---------| | `name` | Store/warehouse name | `"Flagship Store"` | ## Behavior ### `isActive()` Always returns `true`. ### `stock()` Returns `99999` - effectively unlimited stock. ### `productionTime()` Returns `0` - no production delay. ### `commissioningTime()` Returns `0` - no preparation delay. ## Use Cases ### Development & Testing Use the Store adapter during development when you don't need real inventory tracking: ```typescript ``` ### Simple Stores For small shops where inventory is managed manually outside the system. ### Drop-shipping Where stock is always available from suppliers: ```typescript const DropShipAdapter = { ...StoreAdapter, key: 'my-shop.dropship', label: 'Drop Ship', actions(config, context) { return { ...StoreAdapter.actions(config, context), async stock() { // Always available from supplier return 99999; }, async commissioningTime() { // Supplier needs 2-3 days to ship return 3 * 24 * 60 * 60 * 1000; }, }; }, }; ``` ## Extending for Real Inventory For production use, extend with actual inventory tracking: ```typescript WarehousingDirector, WarehousingAdapter, type IWarehousingAdapter } from '@unchainedshop/core'; const RealStoreAdapter: IWarehousingAdapter = { ...WarehousingAdapter, key: 'my-shop.real-store', label: 'Real Store Inventory', version: '1.0.0', orderIndex: 0, typeSupported: (type) => type === 'PHYSICAL', actions(configuration, context) { const { product, modules } = context; return { ...WarehousingAdapter.actions(configuration, context), isActive() { return true; }, configurationError() { return null; }, async stock() { // Get stock from product warehousing data const sku = product?.warehousing?.sku; if (!sku) return 0; // Query your inventory system const inventory = await db.collection('inventory').findOne({ sku }); return inventory?.quantity || 0; }, async productionTime() { const currentStock = await this.stock(); if (currentStock > 0) return 0; // Out of stock - check backorder time const sku = product?.warehousing?.sku; const supplier = await getSupplierLeadTime(sku); return supplier.leadTimeDays * 24 * 60 * 60 * 1000; }, async commissioningTime() { // Standard picking and packing time: 4 hours return 4 * 60 * 60 * 1000; }, }; }, }; WarehousingDirector.registerAdapter(RealStoreAdapter); ``` ## Integration with Delivery Warehousing time affects delivery estimates: ```typescript // In delivery adapter async estimatedDeliveryThroughput(warehousingTime) { // warehousingTime = productionTime + commissioningTime const shippingTime = 3 * 24 * 60 * 60 * 1000; // 3 days shipping return warehousingTime + shippingTime; } ``` ## Query Stock Status ```graphql query ProductAvailability($productId: ID!) { product(productId: $productId) { ... on SimpleProduct { simulatedStocks { warehousingProvider { _id interface { label } } quantity } } } } ``` ## Related - [Plugins Overview](./) - All available plugins - [ETH Minter](./warehousing-eth-minter.md) - Virtual/NFT inventory - [Custom Warehousing Plugins](../../extend/order-fulfilment/fulfilment-plugins/warehousing.md) - Write your own --- ## Worker Plugins Worker plugins handle background tasks like notifications, data processing, and scheduled jobs. ## Base Preset Workers These workers are automatically loaded when using the `base` preset and are strongly recommended for proper system operation: | Adapter Key | Type | Description | When to Use | |-------------|------|-------------|-------------| | [`shop.unchained.worker-plugin.message`](./worker-message.md) | `MESSAGE` | Routes messages through templates to delivery workers | Always β€” central message routing for all notifications | | [`shop.unchained.worker-plugin.email`](./worker-email.md) | `EMAIL` | Email notifications via Nodemailer | When sending transactional emails (order confirmations, etc.) | | [`shop.unchained.worker-plugin.http-request`](./worker-http-request.md) | `HTTP_REQUEST` | Outbound HTTP webhooks | When integrating with external services via webhooks | | [`shop.unchained.worker-plugin.bulk-import`](./worker-bulk-import.md) | `BULK_IMPORT` | Bulk data import from JSON streams | When importing products, prices, or media in bulk | | [`shop.unchained.worker-plugin.external`](./worker-external.md) | `EXTERNAL` | Placeholder for external workers | When delegating work to external systems (e.g. ERP) | | [`shop.unchained.worker-plugin.heartbeat`](./worker-heartbeat.md) | `HEARTBEAT` | System health check | Always β€” monitors worker system health | | [`shop.unchained.worker-plugin.zombie-killer`](./worker-zombie-killer.md) | `ZOMBIE_KILLER` | Cleanup orphaned database records | Always β€” prevents stale data buildup | | [`shop.unchained.worker.error-notifications`](./worker-error-notifications.md) | `ERROR_NOTIFICATIONS` | Daily error reports | When you want daily summaries of worker failures | ## SMS Workers | Adapter Key | Type | Description | |-------------|------|-------------| | [`shop.unchained.worker-plugin.twilio`](./twilio.md) | `TWILIO` | SMS via Twilio | | [`shop.unchained.worker-plugin.bulkgate`](./worker-bulkgate.md) | `BULKGATE` | SMS via BulkGate | | [`shop.unchained.worker-plugin.budgetsms`](./worker-budgetsms.md) | `BUDGETSMS` | SMS via BudgetSMS | ## Push Notifications | Adapter Key | Type | Description | |-------------|------|-------------| | [`shop.unchained.worker-plugin.push-notification`](./push-notification.md) | `PUSH` | W3C Web Push notifications | ## Currency Rate Workers | Adapter Key | Type | Description | |-------------|------|-------------| | [`shop.unchained.worker.update-ecb-rates`](./worker-update-ecb-rates.md) | `UPDATE_ECB_RATES` | EUR exchange rates from ECB | | [`shop.unchained.worker.update-coinbase-rates`](./worker-update-coinbase-rates.md) | `UPDATE_COINBASE_RATES` | Crypto/fiat rates from Coinbase | ## Enrollment Workers | Adapter Key | Type | Description | |-------------|------|-------------| | [`shop.unchained.worker-plugin.generate-enrollment-orders`](./worker-enrollment-order-generator.md) | `ENROLLMENT_ORDER_GENERATOR` | Generate orders from subscriptions | ## Token/NFT Workers | Adapter Key | Type | Description | |-------------|------|-------------| | [`shop.unchained.worker-plugin.export-token`](./worker-export-token.md) | `EXPORT_TOKEN` | Token minting/export process | | [`shop.unchained.worker-plugin.refresh-tokens`](./worker-token-ownership.md) | `REFRESH_TOKENS` | Refresh token ownership data | | [`shop.unchained.worker-plugin.update-token-ownership`](./worker-token-ownership.md) | `UPDATE_TOKEN_OWNERSHIP` | External token ownership verification | ## Creating Custom Worker Plugins See [Custom Worker Plugins](../../extend/worker.md) for creating your own worker adapters. --- ## Push Notification Worker Send W3C compliant web push notifications to subscribed users. ## Installation ```typescript ``` ### Peer Dependency This worker requires the `web-push` package: ```bash npm install web-push ``` ## Environment Variables | Variable | Description | |----------|-------------| | `PUSH_NOTIFICATION_PUBLIC_KEY` | VAPID public key for push service registration | | `PUSH_NOTIFICATION_PRIVATE_KEY` | VAPID private key for signing push messages | ### Generating VAPID Keys ```bash npx web-push generate-vapid-keys ``` ## Usage ### Send Push Notification ```graphql mutation SendPush { addWork( type: PUSH input: { subscription: { endpoint: "https://fcm.googleapis.com/..." expirationTime: null keys: { auth: "auth-key" p256dh: "p256dh-key" } } subject: "https://yourshop.com" payload: "{\"title\": \"Order Shipped\", \"body\": \"Your order is on its way!\"}" urgency: "normal" } ) { _id status } } ``` ## Input Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `subscription` | Object | Yes | User's push subscription object | | `subject` | String | Yes | URL or mailto identifying your service | | `payload` | String | Yes | Stringified JSON with `title` and `body` | | `urgency` | String | No | `very-low`, `low`, `normal`, or `high` | | `topic` | String | No | Identifier for notification coalescing (max 32 chars) | ### Subscription Object ```json { "endpoint": "https://push-service-url...", "expirationTime": null, "keys": { "auth": "authentication-secret", "p256dh": "public-key" } } ``` ## GraphQL API ### Get VAPID Public Key The public key is exposed for client-side subscription: ```graphql query GetShopInfo { shopInfo { vapidPublicKey } } ``` ### Manage User Subscriptions Add subscription: ```graphql mutation AddPushSubscription { addPushSubscription( subscription: { endpoint: "https://fcm.googleapis.com/..." expirationTime: null keys: { auth: "auth-key" p256dh: "p256dh-key" } } unsubscribeFromOtherUsers: true ) { _id pushSubscriptions { _id endpoint userAgent } } } ``` Remove subscription: ```graphql mutation RemovePushSubscription { removePushSubscription(p256dh: "subscription-p256dh-key") { _id } } ``` Note: `unsubscribeFromOtherUsers: true` removes this subscription from other users. ### Query User Subscriptions ```graphql query GetMySubscriptions { me { pushSubscriptions { _id # p256dh value endpoint expirationTime userAgent } } } ``` ## Template Example Send push notifications via the messaging system: ```typescript MessagingDirector.registerTemplate('ORDER_SHIPPED', async ({ orderId }, context) => { const { modules } = context; const order = await modules.orders.findOrder({ orderId }); const user = await modules.users.findUserById(order.userId); const pushNotifications = (user?.pushSubscriptions || []).map((subscription) => ({ type: 'PUSH', input: { subscription, subject: 'https://yourshop.com', payload: JSON.stringify({ title: 'Order Shipped!', body: `Your order ${order.orderNumber} is on its way`, }), }, })); return pushNotifications; }); ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.push-notification` | | Type | `PUSH` | | Source | [worker/push-notification.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/push-notification.ts) | ## External Resources - [W3C Push API Specification](https://www.w3.org/TR/push-api/) - [web-push npm package](https://www.npmjs.com/package/web-push) - [Web Push Notifications Guide (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ## Related - [Message Worker](./worker-message.md) - [Email Worker](./worker-email.md) - [Plugins Overview](./) --- ## Twilio SMS Worker Send SMS messages through the Twilio messaging service. ## Installation ```typescript ``` ## Usage You can send SMS with arbitrary providers through Unchained's work system. To add a new SMS to the system, you can use the `addWork` mutation: ```graphql mutation SendSMS { addWork(type: TWILIO, input: { to: "+1234567890" text: "Your order has shipped!" from: "+0987654321" }) { _id } } ``` Note: The `from` parameter is optional and defaults to `TWILIO_SMS_FROM` env var. The Twilio worker plugin automatically picks up any work items with type `TWILIO` and sends them for you. ## Input Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `to` | String | Yes | Recipient phone number | | `text` | String | No | SMS message content | | `from` | String | No | Sender phone number (defaults to `TWILIO_SMS_FROM`) | Additional Twilio API parameters can be passed and will be forwarded to the API. ## Environment Variables | Variable | Description | |----------|-------------| | `TWILIO_ACCOUNT_SID` | Your Twilio Account SID | | `TWILIO_AUTH_TOKEN` | Your Twilio Auth Token | | `TWILIO_SMS_FROM` | Default sender phone number (must be a Twilio number) | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.twilio` | | Type | `TWILIO` | | Source | [worker/twilio.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/twilio.ts) | ## External Resources - [Twilio SMS API Documentation](https://www.twilio.com/docs/sms) - [Twilio Console (Get Credentials)](https://console.twilio.com/) - [Twilio Phone Numbers](https://www.twilio.com/docs/phone-numbers) ## Related - [BulkGate SMS Worker](./worker-bulkgate.md) - [BudgetSMS Worker](./worker-budgetsms.md) - [Message Worker](./worker-message.md) - [Plugins Overview](./) --- ## BudgetSMS Worker Send SMS messages through the BudgetSMS service with support for test mode. ## Installation ```typescript ``` ## Environment Variables | Variable | Description | |----------|-------------| | `BUDGETSMS_USERNAME` | Your BudgetSMS username (alphanumeric) | | `BUDGETSMS_USERID` | Your BudgetSMS user ID (**numeric only**) | | `BUDGETSMS_HANDLE` | Your BudgetSMS API handle (alphanumeric) | :::warning UserID Must Be Numeric The `BUDGETSMS_USERID` must contain only numbers. You can find it in your BudgetSMS control panel after login. ::: ## Usage ### Send Real SMS ```graphql mutation SendSMS { addWork( type: BUDGETSMS input: { to: "+41791234567" text: "Your verification code is 123456" from: "YourCompany" price: true credit: true } ) { _id status } } ``` Note: `price: true` includes price info in response, `credit: true` includes remaining credit. ### Test Mode (No Credit Deducted) ```graphql mutation TestSMS { addWork( type: BUDGETSMS input: { to: "+41791234567" text: "Test message" from: "YourCompany" test: true } ) { _id status } } ``` ## Input Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `to` | String | - | Phone number in international format (required) | | `text` | String | - | SMS message content | | `from` | String | - | Sender name (max 11 alphanumeric or 16 numeric chars) | | `test` | Boolean | `false` | Use test endpoint (no credit deducted) | | `customid` | String | - | Custom ID for tracking | | `price` | Boolean | `false` | Include price info in response | | `mccmnc` | Boolean | `false` | Include carrier info in response | | `credit` | Boolean | `false` | Include remaining credit in response | ## Result ### Success ```json { "sms_id": "123456789", "status": "sent", "test_mode": false, "price": 0.05, "parts": 1, "remaining_credit": 99.95 } ``` ### Test Mode Success ```json { "sms_id": "123456789", "status": "test_successful", "test_mode": true, "message": "Test SMS validated successfully (no credit deducted)" } ``` ## Error Codes | Code | Description | |------|-------------| | 1001 | Authentication failed OR insufficient credit | | 1002 | Account not active | | 1003 | Insufficient credit | | 2001 | SMS message text is empty | | 2005 | Destination number too short | | 2012 | SMS message text too long | :::tip Error 1001 Ambiguity BudgetSMS returns error 1001 for both authentication failures AND when you have zero credit. Use the test endpoint to distinguish between these cases. ::: ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.budgetsms` | | Type | `BUDGETSMS` | | Source | [worker/budgetsms.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/budgetsms.ts) | ## External Resources - [BudgetSMS API Documentation](https://www.budgetsms.net/sms-http-api/send-sms/) - [BudgetSMS Control Panel](https://www.budgetsms.net/) ## Related - [Twilio SMS Worker](./twilio.md) - [BulkGate SMS Worker](./worker-bulkgate.md) - [Plugins Overview](./) --- ## Bulk Import Worker Processes large data imports from JSON streams with event-based processing. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Features - **Streaming Processing**: Handles large files without memory issues - **JSON Stream Parsing**: Parses JSON events from uploaded files - **Event-Based**: Processes import events one by one - **File Adapter Integration**: Works with any Unchained file storage adapter - **Backpressure Handling**: Automatic flow control for large datasets ## Usage Upload a JSON file with import events: ```json { "events": [ { "type": "PRODUCT", "operation": "CREATE", "payload": { "sku": "PRODUCT-001", "title": "Sample Product" } }, { "type": "PRODUCT", "operation": "UPDATE", "payload": { "sku": "PRODUCT-002", "title": "Updated Product" } } ] } ``` ## Supported Event Types - `PRODUCT` - Create/update products - `ASSORTMENT` - Create/update assortments - `FILTER` - Create/update filters - `ENROLLMENT` - Create/update enrollments ## Triggering Import ### From Uploaded File ```graphql mutation CreateBulkImportWork { addWork( type: BULK_IMPORT input: { payloadId: "uploaded-file-id" createShouldUpsertIfIDExists: false updateShouldUpsertIfIDNotExists: false skipCacheInvalidation: false } ) { _id status } } ``` ### From Direct Events ```typescript // Direct events must be passed programmatically await unchainedAPI.modules.worker.addWork({ type: 'BULK_IMPORT', input: { events: [ { type: 'PRODUCT', operation: 'CREATE', payload: { sku: 'PRODUCT-001' } } ] } }); ``` ## Input Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `payloadId` | String | - | File ID of uploaded JSON stream | | `events` | Array | - | Direct array of import events (alternative to payloadId) | | `createShouldUpsertIfIDExists` | Boolean | `false` | Upsert on CREATE if ID already exists | | `updateShouldUpsertIfIDNotExists` | Boolean | `false` | Upsert on UPDATE if ID doesn't exist | | `skipCacheInvalidation` | Boolean | `false` | Skip cache invalidation after import | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.bulk-import` | | Source | [worker/bulk-import.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/bulk-import.ts) | ## Related - [Bulk Import Guide](../../guides/bulk-import.md) - [File Storage Plugins](../files/file-minio.md) - [Plugins Overview](./) --- ## BulkGate SMS Worker Send transactional and promotional SMS messages through the BulkGate service. ## Installation ```typescript ``` ## Environment Variables | Variable | Description | |----------|-------------| | `BULKGATE_APPLICATION_ID` | Your BulkGate application ID | | `BULKGATE_APPLICATION_TOKEN` | Your BulkGate application token | ## Usage ### Transactional SMS ```graphql mutation SendTransactionalSMS { addWork( type: BULKGATE input: { to: "+1234567890" text: "Your order #123 has shipped!" from: "YourCompany" } ) { _id status } } ``` ### Promotional SMS ```graphql mutation SendPromotionalSMS { addWork( type: BULKGATE input: { to: "+1234567890;+0987654321" text: "Special offer: 20% off!" promotional: true } ) { _id status } } ``` Note: Use semicolon-separated numbers for multiple recipients with `promotional: true`. ## Input Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `to` | String | - | Phone number(s) in international format | | `text` | String | - | SMS message content | | `from` | String | - | Sender name (uses `gText` sender type) | | `unicode` | Boolean | `false` | Enable unicode character support | | `country` | String | - | Country code for number validation | | `schedule` | String/Number | - | ISO 8601 date or Unix timestamp for scheduled delivery | | `promotional` | Boolean | `false` | Use promotional API (allows multiple recipients) | ## Sender Types - **Without `from`**: Uses `gSystem` (system number) - **With `from`**: Uses `gText` (custom alphanumeric sender, max 11 chars) ## Result ```json { "sms_id": "123456789", "price": 0.05, "credit": 99.95, "number": "+1234567890", "status": "accepted" } ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.bulkgate` | | Type | `BULKGATE` | | Source | [worker/bulkgate.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/bulkgate.ts) | ## External Resources - [BulkGate API Documentation](https://help.bulkgate.com/docs/en/http-simple-transactional-post-json.html) - [BulkGate Portal](https://portal.bulkgate.com/) ## Related - [Twilio SMS Worker](./twilio.md) - [BudgetSMS Worker](./worker-budgetsms.md) - [Plugins Overview](./) --- ## Email Worker Handles email notifications using Nodemailer with development-friendly features. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `MAIL_URL` | - | SMTP connection URL (required in production) | | `UNCHAINED_DISABLE_EMAIL_INTERCEPTION` | `false` | Disable email interception in non-production | ## Features - **Development Mode**: Automatically opens emails in browser instead of sending - **Nodemailer Integration**: Full Nodemailer support for email transport - **HTML/Text Support**: Handles both HTML and plain text emails - **Attachment Support**: Full attachment support with various encoding options - **Email Preview**: Browser-based email preview for development ## Configuration Configure the MAIL_URL for your SMTP provider: ```bash # Gmail MAIL_URL=smtp://user:pass@smtp.gmail.com:587 # Mailgun MAIL_URL=smtp://postmaster@mg.example.com:password@smtp.mailgun.org:587 # SendGrid MAIL_URL=smtp://apikey:SG.xxx@smtp.sendgrid.net:587 ``` ## Development vs Production ### Development In non-production environments, emails are intercepted and opened in the browser for preview. This prevents accidental emails to real users during development. To disable interception: ```bash UNCHAINED_DISABLE_EMAIL_INTERCEPTION=true ``` ### Production In production (`NODE_ENV=production`), emails are sent through the configured SMTP transport. ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.email` | | Source | [worker/email.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/email.ts) | ## Related - [Messaging Configuration](../../platform-configuration/messaging.md) - [Plugins Overview](./) --- ## Enrollment Order Generator Worker Automatically generates orders from active and paused enrollments based on their configured periods. ## Installation ```typescript ``` ## Purpose This worker processes enrollments (subscriptions) and: - Checks all `ACTIVE` and `PAUSED` enrollments - Determines if a new period should begin using the Enrollment Director - Creates trial periods without orders - Generates orders for billable periods - Tracks periods on the enrollment ## Auto-Scheduling To enable automatic order generation, configure the scheduling in your platform setup: ```typescript // Configure the schedule (e.g., daily at midnight) enrollmentsSettings.autoSchedulingSchedule = later.parse.cron('0 0 * * *'); // Enable auto-scheduling configureGenerateOrderAutoscheduling(); ``` ## Manual Trigger You can also trigger order generation manually: ```graphql mutation GenerateEnrollmentOrders { addWork(type: ENROLLMENT_ORDER_GENERATOR) { _id status } } ``` ## How It Works 1. **Find Enrollments**: Queries all enrollments with status `ACTIVE` or `PAUSED` 2. **Check Period**: Uses the Enrollment Director to determine if a new period should start 3. **Trial Periods**: If the period is a trial, adds the period without creating an order 4. **Order Generation**: For billable periods: - Gets configuration from the director - Creates an order using the enrollment service - Links the order to the enrollment period 5. **Error Handling**: Collects errors for all enrollments and reports them in the result ## Result ### Success ```json { "success": true } ``` ### Partial Failure ```json { "success": false, "error": { "name": "SOME_ENROLLMENTS_COULD_NOT_PROCESS", "message": "Some errors have been reported during order generation", "logs": [ { "name": "Error", "message": "Product not found" } ] } } ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.generate-enrollment-orders` | | Type | `ENROLLMENT_ORDER_GENERATOR` | | Retries | 5 (when auto-scheduled) | | Source | [worker/enrollment-order-generator.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/enrollment-order-generator.ts) | ## Related - [Enrollments Module](../../platform-configuration/modules/enrollments.md) - [Plugins Overview](./) --- ## Error Notifications Worker Sends daily reports about work items that have permanently failed (exhausted all retries). :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Purpose The Error Notifications Worker helps you stay informed about system issues by: - Running automatically every day at 3 AM UTC - Collecting all permanently failed work items from the past 24 hours - Triggering a MESSAGE work item with the `ERROR_REPORT` template - Excluding its own failures to prevent notification loops ## Auto-Scheduling When imported, this worker automatically schedules itself to run daily at 03:00 UTC. ## Manual Trigger You can also trigger a report manually: ```graphql mutation SendErrorReport { addWork( type: ERROR_NOTIFICATIONS input: { secondsPassed: 86400 } ) { _id status } } ``` Note: `secondsPassed` is optional and defaults to 24 hours (86400 seconds). ## Input Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `secondsPassed` | Number | `86400` | Seconds to look back for failed work items | ## Setting Up the Template To receive error notifications, you need to register an `ERROR_REPORT` template: ```typescript MessagingDirector.registerTemplate('ERROR_REPORT', async ({ workItems }, context) => { const adminEmail = 'admin@example.com'; const summary = workItems.map(work => `- ${work.type}: ${work.error?.message || 'Unknown error'}` ).join('\n'); return [{ type: 'EMAIL', input: { to: adminEmail, subject: `[Unchained] ${workItems.length} failed work items`, text: `The following work items have permanently failed:\n\n${summary}`, }, }]; }); ``` ## Result ```json { "forked": "message-work-id" // ID of the created MESSAGE work } ``` If no failed work items are found, the result will be empty and no message is sent. ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker.error-notifications` | | Type | `ERROR_NOTIFICATIONS` | | Auto-Schedule | Daily at 03:00 UTC | | Retries | 0 | | Source | [worker/error-notifications.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/error-notifications.ts) | ## Related - [Message Worker](./worker-message.md) - [Email Worker](./worker-email.md) - [Plugins Overview](./) --- ## Export Token Worker An external worker placeholder for managing NFT/token minting and export processes. ## Installation ```typescript ``` ## Purpose The Export Token Worker: - Acts as a placeholder for external token minting systems - Tracks the state of token export/minting processes - Automatically updates token ownership when work completes successfully - Listens for work completion events to trigger ownership updates ## Configuration To enable automatic ownership updates, configure the worker in your platform setup: ```typescript // Pass the unchained API to enable event listeners configureExportToken(unchainedAPI); ``` ## How It Works 1. **Create Export Work**: External system creates work with token details 2. **External Processing**: The minting/export happens outside Unchained 3. **Complete Work**: External system marks work as finished via GraphQL 4. **Ownership Update**: Worker automatically updates token ownership in the database ## Usage ### Create Export Work (from external system) ```graphql mutation CreateExportWork { addWork( type: EXPORT_TOKEN input: { token: { _id: "token-id" contractAddress: "0x..." tokenId: "123" } recipientWalletAddress: "0xRecipientAddress..." } ) { _id status } } ``` ### Complete Export Work (from external system) ```graphql mutation CompleteExport { finishWork( workId: "work-id" success: true result: { transactionHash: "0x...", blockNumber: 12345 } ) { _id status } } ``` ## Event Handling When a work item of type `EXPORT_TOKEN` completes successfully, the worker: 1. Extracts the token ID from `work.input.token._id` 2. Extracts the wallet address from `work.input.recipientWalletAddress` 3. Updates the token ownership in the warehousing module ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.export-token` | | Type | `EXPORT_TOKEN` | | External | `true` | | Source | [worker/export-token.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/export-token.ts) | ## Related - [Token Ownership Workers](./worker-token-ownership.md) - [ETH Minter Warehousing Plugin](../warehousing/warehousing-eth-minter.md) - [External Worker](./worker-external.md) - [Plugins Overview](./) --- ## External Worker A placeholder adapter for workers that are processed by external systems and interact with Unchained only via GraphQL. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Purpose The External Worker serves as a placeholder for work items that are: - Processed by systems outside of Unchained - Updated via GraphQL mutations from external services - Used to track the state of externally-managed background tasks This adapter cannot process work internally - it throws an error if `doWork` is called directly. Instead, external systems should: 1. Query for pending work items of type `EXTERNAL` 2. Process the work externally 3. Update the work status via GraphQL ## Usage Create external work: ```graphql mutation CreateExternalWork { addWork( type: EXTERNAL input: { customData: "any-payload-for-external-system" } ) { _id status } } ``` Mark work as completed from external system: ```graphql mutation FinishExternalWork { finishWork( workId: "work-id" result: { processed: true } success: true ) { _id status } } ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.external` | | Type | `EXTERNAL` | | External | `true` | | Source | [worker/external.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/external.ts) | ## Related - [Export Token Worker](./worker-export-token.md) - [Update Token Ownership Worker](./worker-token-ownership.md) - [Plugins Overview](./) --- ## Heartbeat Worker A simple test worker used to verify that the worker system is functioning correctly. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Purpose The Heartbeat Worker is primarily used for: - Testing that the worker queue is processing jobs - Debugging worker system issues - Health checks in monitoring systems - Simulating work delays for testing ## Usage Create a heartbeat work item: ```graphql mutation TestWorker { addWork( type: HEARTBEAT input: { wait: 1000 fails: false } ) { _id status } } ``` Note: `wait` is milliseconds to wait before completing, `fails: true` simulates a failure. ## Input Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `wait` | Number | - | Milliseconds to wait before completing | | `fails` | Boolean | `false` | If `true`, the work will fail instead of succeed | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.heartbeat` | | Type | `HEARTBEAT` | | Max Parallel | 1 | | Source | [worker/heartbeat.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/heartbeat.ts) | ## Related - [Worker System](../../extend/worker.md) - [Plugins Overview](./) --- ## HTTP Request Worker Handles outbound HTTP requests for webhooks and external API integrations. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Features - **Outbound Webhooks**: Send HTTP requests to external services - **Configurable Methods**: Support for GET and POST methods - **Headers Support**: Custom headers for authentication - **JSON Payloads**: Automatic JSON serialization for POST requests - **Response Parsing**: Automatic JSON/text response handling ## Usage Create HTTP request work: ```graphql mutation CreateWebhook { addWork( type: HTTP_REQUEST input: { url: "https://api.example.com/webhook" method: "POST" headers: { Authorization: "Bearer token" } data: { event: "order.created", orderId: "123" } } ) { _id status } } ``` ## Input Parameters | Parameter | Type | Description | |-----------|------|-------------| | `url` | String | Target URL (required) | | `method` | String | HTTP method: `GET` or `POST` (default: POST) | | `headers` | Object | Request headers | | `data` | Object | Request body for POST requests (JSON serialized) | ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.http-request` | | Source | [worker/http-request.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/http-request.ts) | ## Related - [Events System](../events/events-node.md) - [Worker System](../../extend/worker.md) - [Plugins Overview](./) --- ## Message Worker Routes messages through template resolvers to create concrete delivery work items (email, SMS, push notifications, etc.). :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Purpose The Message Worker is the central routing hub for all notifications in Unchained: 1. Receives a template name and payload 2. Resolves the template using the Messaging Director 3. Creates concrete work items (EMAIL, PUSH, TWILIO, etc.) 4. Links all created work to the original message work ## Usage ```graphql mutation SendWelcomeMessage { addWork( type: MESSAGE input: { template: "ACCOUNT_ACTION" userId: "user-id" action: "verify-email" } ) { _id status } } ``` Note: Any additional payload for the template can be added to the `input` object. ## Input Parameters | Parameter | Type | Description | |-----------|------|-------------| | `template` | String | Name of the registered template (required) | | `...payload` | Any | Additional data passed to the template resolver | ## Registering Templates Templates are registered using the Messaging Director: ```typescript MessagingDirector.registerTemplate('ORDER_CONFIRMATION', async (payload, context) => { const { modules } = context; const { orderId } = payload; const order = await modules.orders.findOrder({ orderId }); const user = await modules.users.findUserById(order.userId); const workItems = []; // Send email workItems.push({ type: 'EMAIL', input: { to: user.emails[0].address, subject: `Order ${order.orderNumber} confirmed`, html: `Thank you for your order!...`, }, }); // Send push notification if subscribed for (const subscription of user.pushSubscriptions || []) { workItems.push({ type: 'PUSH', input: { subscription, subject: 'https://shop.example.com', payload: JSON.stringify({ title: 'Order Confirmed', body: `Order ${order.orderNumber} is confirmed`, }), }, }); } return workItems; }); ``` ## Result ```json { "forked": [ { "_id": "email-work-id", "type": "EMAIL", "status": "ALLOCATED" }, { "_id": "push-work-id", "type": "PUSH", "status": "ALLOCATED" } ] } ``` ## Built-in Templates Unchained uses the following templates internally: - `ACCOUNT_ACTION` - Email verification, password reset - `ORDER_CONFIRMATION` - Order confirmed - `DELIVERY` - Delivery notifications - `ERROR_REPORT` - Daily error reports ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.message` | | Type | `MESSAGE` | | Source | [worker/message.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/message.ts) | ## Related - [Email Worker](./worker-email.md) - [Push Notification Worker](./push-notification.md) - [Twilio SMS Worker](./twilio.md) - [Plugins Overview](./) --- ## Token Ownership Workers Two workers for managing NFT/token ownership: one for refreshing tokens and one for external ownership updates. ## Installation ```typescript ``` ## Workers Included This file registers two workers: ### 1. Refresh Tokens Worker Automatically finds all tokens and accounts, then triggers ownership updates. | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.refresh-tokens` | | Type | `REFRESH_TOKENS` | | Auto-Schedule | Every minute | ### 2. Update Token Ownership Worker (External) External worker placeholder for the actual ownership verification process. | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.update-token-ownership` | | Type | `UPDATE_TOKEN_OWNERSHIP` | | External | `true` | ## How It Works ### Refresh Tokens Flow 1. **Auto-scheduled** every minute 2. **Collects tokens**: Finds all tokens with wallet addresses 3. **Collects accounts**: Finds all users with verified Web3 addresses 4. **Creates work**: Triggers `UPDATE_TOKEN_OWNERSHIP` with the token and account data 5. **External processing**: An external system processes the ownership verification ### External Update Flow 1. External system receives `UPDATE_TOKEN_OWNERSHIP` work 2. Verifies token ownership on the blockchain 3. Marks work as complete via GraphQL ## Manual Trigger ### Refresh All Tokens ```typescript // Trigger via API await unchainedAPI.modules.worker.addWork({ type: 'REFRESH_TOKENS', }); ``` ### Update Specific Tokens (External) ```typescript // Trigger via API await unchainedAPI.modules.worker.addWork({ type: 'UPDATE_TOKEN_OWNERSHIP', input: { filter: { tokens: [{ _id: 'token-1' }, { _id: 'token-2' }], accounts: ['0xAddress1', '0xAddress2'], }, }, }); ``` ## Result ### Refresh Tokens ```json { "forked": "update-token-ownership-work-id" } ``` ## Input for UPDATE_TOKEN_OWNERSHIP | Parameter | Type | Description | |-----------|------|-------------| | `filter.tokens` | Array | Token objects to check ownership for | | `filter.accounts` | Array | Wallet addresses to verify | ## Adapter Details ### REFRESH_TOKENS | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.refresh-tokens` | | Type | `REFRESH_TOKENS` | | Auto-Schedule | Every minute | | Retries | 0 | ### UPDATE_TOKEN_OWNERSHIP | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.update-token-ownership` | | Type | `UPDATE_TOKEN_OWNERSHIP` | | External | `true` | ### Source [worker/update-token-ownership.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/update-token-ownership.ts) ## Related - [Export Token Worker](./worker-export-token.md) - [ETH Minter Warehousing Plugin](../warehousing/warehousing-eth-minter.md) - [Plugins Overview](./) --- ## Coinbase Exchange Rate Worker Automatically fetches and updates currency exchange rates from Coinbase, supporting both fiat and cryptocurrency pairs. ## Installation ```typescript ``` ## Purpose Coinbase provides real-time exchange rates for a wide variety of currencies including cryptocurrencies. This worker: - Fetches the latest rates from the Coinbase API - Uses your system's default currency as the base - Updates product price rates with a 5-minute expiration - Automatically schedules itself to run every minute ## Auto-Scheduling When imported, this worker automatically schedules itself to run every minute to keep cryptocurrency rates up-to-date. ## Manual Trigger You can also trigger an update manually: ```graphql mutation UpdateRates { addWork(type: UPDATE_COINBASE_RATES) { _id status } } ``` ## Supported Currencies Coinbase provides rates for: - **Fiat currencies**: USD, EUR, GBP, CHF, and many more - **Cryptocurrencies**: BTC, ETH, USDC, and hundreds of others Only currencies that are enabled in your Unchained configuration will be updated. ## Rate Expiration Rates are set to expire after 5 minutes, ensuring that: - Stale cryptocurrency rates are not used - The system falls back gracefully if the worker stops ## Result ```json { "ratesUpdated": 15 } ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker.update-coinbase-rates` | | Type | `UPDATE_COINBASE_RATES` | | Auto-Schedule | Every minute | | Source | [worker/update-coinbase-rates.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/update-coinbase-rates.ts) | ## Related - [ECB Rates Worker](./worker-update-ecb-rates.md) - [Multi-Currency Setup](../../guides/multi-currency-setup.md) - [Plugins Overview](./) --- ## ECB Exchange Rate Worker Automatically fetches and updates EUR-based currency exchange rates from the European Central Bank. ## Installation ```typescript ``` ### Peer Dependency This worker requires the `xml-js` package: ```bash npm install xml-js ``` ## Purpose The ECB publishes daily reference exchange rates for major currencies against EUR. This worker: - Fetches the latest rates from the ECB XML feed - Updates product price rates in the database - Automatically schedules itself daily at 15:00 UTC (4 PM CET) ## Auto-Scheduling When imported, this worker automatically schedules itself to run daily at 15:00 UTC, which is after the ECB publishes new rates (around 16:00 CET). ## Manual Trigger You can also trigger an update manually: ```graphql mutation UpdateRates { addWork(type: UPDATE_ECB_RATES) { _id status } } ``` ## Supported Currencies The ECB provides rates for approximately 30 currencies including: - USD, GBP, JPY, CHF, CAD, AUD - SEK, NOK, DKK, PLN, CZK, HUF - And many more Only currencies that are enabled in your Unchained configuration will be updated. ## Requirements - EUR must be an enabled currency in your system - Target currencies must also be enabled - The `xml-js` npm package must be installed ## Result ```json { "ratesUpdated": 25, "info": "EUR not enabled" // Only if EUR is not configured } ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker.update-ecb-rates` | | Type | `UPDATE_ECB_RATES` | | Auto-Schedule | Daily at 15:00 UTC | | Retries | 5 | | Source | [worker/update-ecb-rates.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/update-ecb-rates.ts) | ## Related - [Coinbase Rates Worker](./worker-update-coinbase-rates.md) - [Multi-Currency Setup](../../guides/multi-currency-setup.md) - [Plugins Overview](./) --- ## Zombie Killer Worker Cleans up orphaned database records and files that are no longer referenced by their parent entities. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. ::: ## Installation ```typescript ``` ## Purpose The Zombie Killer Worker removes "zombie" data - records that have become orphaned due to deletions or data inconsistencies: - **Filter texts** without parent filters - **Assortment texts** without parent assortments - **Assortment media** without parent assortments - **Product texts** without parent products - **Product variations** without parent products - **Product media** without parent products - **Unreferenced files** in product-media and assortment-media paths - **Old bulk import streams** older than a configurable age ## Usage Trigger a cleanup: ```graphql mutation CleanupZombies { addWork( type: ZOMBIE_KILLER input: { bulkImportMaxAgeInDays: 5 } ) { _id status } } ``` Note: `bulkImportMaxAgeInDays` is optional and defaults to 5. ## Input Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `bulkImportMaxAgeInDays` | Number | `5` | Days after which bulk import streams are deleted | ## Result The worker returns counts of deleted items: ```json { "deletedFilterTextsCount": 0, "deletedAssortmentTextsCount": 0, "deletedAssortmentMediaCount": 0, "deletedProductTextsCount": 0, "deletedProductVariationsCount": 0, "deletedProductMediaCount": 0, "deletedFilesCount": 0 } ``` ## Recommended Schedule Consider running this worker periodically (e.g., weekly) to keep your database clean: ```typescript WorkerDirector.configureAutoscheduling({ type: 'ZOMBIE_KILLER', schedule: schedule.parse.cron('0 3 * * 0'), // Every Sunday at 3 AM input: { bulkImportMaxAgeInDays: 7 }, }); ``` ## Adapter Details | Property | Value | |----------|-------| | Key | `shop.unchained.worker-plugin.zombie-killer` | | Type | `ZOMBIE_KILLER` | | Source | [worker/zombie-killer.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/worker/zombie-killer.ts) | ## Related - [Bulk Import Worker](./worker-bulk-import.md) - [File Storage Plugins](../files/) - [Plugins Overview](./) --- ## Create Your First Order This guide walks you through the complete checkout flow to create your first order. ## Prerequisites - Unchained Engine running locally (see [Initialize and Run](./run-local)) - At least one product created (see [Your First Product](./first-product)) - Payment and delivery providers configured ## Using the Storefront The easiest way to create an order is through the storefront. ### 1. Browse Products 1. Open http://localhost:3000 2. Browse the product catalog 3. Find the product you created ### 2. Add to Cart 1. Click on a product to view details 2. Select quantity 3. Click **Add to Cart** ### 3. View Cart 1. Click the cart icon 2. Review your items 3. Adjust quantities if needed ### 4. Checkout 1. Click **Proceed to Checkout** 2. If prompted, log in or continue as guest 3. Enter shipping address 4. Select delivery method 5. Select payment method 6. Review order total 7. Click **Place Order** ### 5. Confirm Order After placing the order: - You'll see an order confirmation page - Check the Admin UI for the new order - Email confirmation will be sent (check the built-in email preview) ## Using GraphQL For testing or programmatic orders, use the GraphQL API. ### Step 1: Login as Guest ```graphql mutation LoginAsGuest { loginAsGuest { _id tokenExpires user { _id isGuest } } } ``` :::info Authentication Unchained uses **cookie-based authentication**. When you call `loginAsGuest` (or any login mutation), the server responds with a `Set-Cookie` header containing `unchained_token`. **In the GraphQL Playground:** - Cookies are automatically handled if you enable "request.credentials" in settings - Go to Settings (gear icon) and set `"request.credentials": "include"` **In your application:** - Ensure your HTTP client is configured to include credentials (cookies) - Example with fetch: `fetch(url, { credentials: 'include' })` ::: ### Step 2: Add Product to Cart ```graphql mutation AddToCart { addCartProduct(productId: "your-product-id", quantity: 1) { _id quantity product { texts { title } } total { amount currencyCode } order { _id total { amount currencyCode } } } } ``` ### Step 3: Get Available Providers & Set Delivery/Payment Get available delivery and payment providers: ```graphql query GetProviders { me { cart { supportedDeliveryProviders { _id type interface { label } } supportedPaymentProviders { _id type interface { label } } } } } ``` Set both providers in one call: ```graphql mutation SetProviders { updateCart( deliveryProviderId: "your-delivery-provider-id" paymentProviderId: "your-payment-provider-id" ) { _id delivery { provider { interface { label } } } payment { provider { interface { label } } } } } ``` ### Step 4: Set Delivery Address ```graphql mutation SetDeliveryAddress { updateCartDeliveryShipping( deliveryProviderId: "your-delivery-provider-id" address: { firstName: "John" lastName: "Doe" addressLine: "123 Main Street" postalCode: "8000" city: "Zurich" countryCode: "CH" } ) { _id } } ``` ### Step 5: Set Billing Address :::caution Required Step A billing address is required for checkout. Without it, the checkout will fail with `OrderCheckoutError: Billing address not provided`. ::: ```graphql mutation SetBillingAddress { updateCart(billingAddress: { firstName: "John" lastName: "Doe" addressLine: "123 Main Street" postalCode: "8000" city: "Zurich" countryCode: "CH" }) { _id billingAddress { firstName lastName city } } } ``` ### Step 6: Review & Checkout Review your cart and checkout in one step: ```graphql query ReviewCart { me { cart { _id items { quantity product { texts { title } } total { amount currencyCode } } delivery { ... on OrderDeliveryShipping { address { firstName lastName city } } fee { amount currencyCode } } payment { provider { interface { label } } fee { amount currencyCode } } total { amount currencyCode } } } } ``` ### Step 7: Checkout ```graphql mutation Checkout { checkoutCart { _id status orderNumber ordered total { amount currencyCode } } } ``` ## Understanding Order Status After checkout, your order will have one of these statuses: | Status | Meaning | Next Step | |--------|---------|-----------| | `PENDING` | Awaiting payment confirmation | Payment webhook or manual confirmation | | `CONFIRMED` | Payment received, ready for delivery | Process delivery | | `FULFILLED` | Delivered and complete | None - order finished | For the default Invoice payment provider, orders typically go directly to `CONFIRMED` since `isPayLaterAllowed` returns `true`. ## Viewing Orders ### In Admin UI 1. Open http://localhost:4010 2. Click **Orders** in the sidebar 3. Find your order by order number 4. Click to view details ### Via GraphQL As admin: ```graphql query GetOrders { orders(limit: 10) { _id orderNumber status ordered user { primaryEmail { address } } total { amount currencyCode } } } ``` Single order: ```graphql query GetOrder($orderId: ID!) { order(orderId: $orderId) { _id orderNumber status items { quantity product { texts { title } } total { amount currencyCode } } delivery { ... on OrderDeliveryShipping { address { firstName lastName addressLine city } } status } payment { status } total { amount currencyCode } } } ``` ## Managing Orders ### Confirm Order Manually If order is in `PENDING` status: ```graphql mutation ConfirmOrder { confirmOrder(orderId: "order-id") { _id status } } ``` ### Reject Order ```graphql mutation RejectOrder { rejectOrder(orderId: "order-id") { _id status } } ``` ### Mark as Delivered ```graphql mutation MarkDelivered { deliverOrder(orderId: "order-id") { _id status delivery { status } } } ``` ## Common Issues ### "No delivery provider" Error - Ensure you've created at least one delivery provider in Admin UI - Check that the delivery provider is active - Verify the delivery provider supports your country ### "No payment provider" Error - Create at least one payment provider in Admin UI - Ensure it's active - For testing, use the Invoice provider with "Pay Later" enabled ### Order Stuck in PENDING - Check payment provider configuration - For Invoice provider, orders should auto-confirm - For card payments, verify webhook is set up correctly ### Cart is Empty - Ensure you're authenticated (guest or registered) - Check that `Authorization` header is set correctly - Verify products are published ## Order Lifecycle Diagram ```mermaid stateDiagram-v2 [*] --> OPEN: Cart OPEN --> PENDING: checkoutCart PENDING --> CONFIRMED: confirmOrder PENDING --> REJECTED: rejectOrder CONFIRMED --> FULFILLED: deliverOrder ``` ## Next Steps Now that you've created your first order, explore: - [Order Lifecycle](../concepts/order-lifecycle) - Understand order states - [Payment Integration](../guides/payment-integration) - Set up real payments - [Checkout Implementation](../guides/checkout-implementation) - Build custom checkout - [Platform Configuration](../platform-configuration/) - Customize your shop --- ## Create Your First Product This guide walks you through creating and configuring your first product in Unchained Engine. ## Prerequisites - Unchained Engine running locally (see [Initialize and Run](./run-local)) - Admin UI accessible at http://localhost:4010 ## Using the Admin UI The easiest way to create products is through the Admin UI. ### 1. Navigate to Products 1. Open http://localhost:4010 2. Log in with your admin credentials 3. Click **Products** in the sidebar ### 2. Create a Simple Product 1. Click **Create Product** 2. Select **Simple Product** as the type 3. Fill in the basic information: - **Title**: "Organic Cotton T-Shirt" - **Subtitle**: "Comfortable everyday wear" - **Description**: "Made from 100% organic cotton..." ### 3. Set the Price 1. Go to the **Commerce** tab 2. Click **Add Price** 3. Set: - **Currency**: CHF (or your default currency) - **Amount**: 49.00 - **Taxable**: Yes - **Net Price**: Yes ### 4. Add Media (Optional) 1. Go to the **Media** tab 2. Click **Upload** 3. Select your product image ### 5. Publish the Product 1. Change **Status** from "Draft" to "Active" 2. Click **Save** Your product is now visible in the storefront! ## Using GraphQL For programmatic product creation, use the GraphQL API. ### Create Product ```graphql mutation CreateProduct { createProduct(product: { type: SIMPLE_PRODUCT tags: ["clothing", "cotton", "organic"] }) { _id status } } ``` ### Add Translations ```graphql mutation AddProductTexts { updateProductTexts(productId: "your-product-id", texts: [ { locale: "en" title: "Organic Cotton T-Shirt" subtitle: "Comfortable everyday wear" description: "Made from 100% organic cotton, this t-shirt is perfect for everyday wear. Soft, breathable, and sustainably sourced." slug: "organic-cotton-t-shirt" } { locale: "de" title: "Bio-Baumwoll T-Shirt" subtitle: "Bequeme Alltagskleidung" description: "Aus 100% Bio-Baumwolle, dieses T-Shirt ist perfekt fΓΌr den Alltag. Weich, atmungsaktiv und nachhaltig beschafft." slug: "bio-baumwoll-t-shirt" } ]) { _id } } ``` ### Set Pricing Use `updateProductCommerce` to add prices to your product: ```graphql mutation SetProductPricing { updateProductCommerce(productId: "your-product-id", commerce: { pricing: [ { currencyCode: "CHF" countryCode: "CH" amount: 4900 isTaxable: true isNetPrice: true }, { currencyCode: "EUR" countryCode: "DE" amount: 4500 isTaxable: true isNetPrice: true } ] }) { _id } } ``` ### Publish Product ```graphql mutation PublishProduct { publishProduct(productId: "your-product-id") { _id status published } } ``` ## Product Types Unchained supports several product types: | Type | Description | Use Case | |------|-------------|----------| | `SIMPLE_PRODUCT` | Basic product with price | Physical goods, digital downloads | | `CONFIGURABLE_PRODUCT` | Product with variations | Clothing sizes, colors | | `BUNDLE_PRODUCT` | Collection of products | Gift sets, starter kits | | `PLAN_PRODUCT` | Subscription product | Memberships, recurring services | | `TOKENIZED_PRODUCT` | NFT/token-backed product | Digital collectibles | ### Configurable Product Example ```graphql mutation CreateConfigurableProduct { createProduct(product: { type: CONFIGURABLE_PRODUCT tags: ["clothing"] }) { _id } } # Note: Variations are managed through product assignments and vectors. # The variations field is not directly settable on updateProduct. # Instead, you define variation options on child products and link them # to the parent configurable product using addProductAssignment with vectors. # Link variant products mutation LinkVariant { addProductAssignment(proxyId: "configurable-id", productId: "simple-variant-id", vectors: [ { key: "size", value: "M" } { key: "color", value: "black" } ]) { _id } } ``` ## Adding to an Assortment Products can be organized into assortments (categories): ```graphql # Create assortment first mutation CreateAssortment { createAssortment(assortment: { isRoot: true tags: ["main-nav"] # Note: isActive is not available on CreateAssortmentInput. # Set the status after creation using separate mutations if needed. }) { _id } } # Add texts mutation AddAssortmentTexts { updateAssortmentTexts(assortmentId: "assortment-id", texts: [ { locale: "en", title: "Clothing", slug: "clothing" } ]) { _id } } # Link product to assortment mutation AddProductToAssortment { addAssortmentProduct(assortmentId: "assortment-id", productId: "product-id") { _id } } ``` ## Verify in Storefront After creating your product: 1. Open http://localhost:3000 2. Your product should appear in the product list 3. Click on it to view the product detail page 4. Verify the price and description are correct ## Common Issues ### Product Not Visible - Check that status is "Active" (not "Draft") - Ensure at least one price is set - Verify product is assigned to an assortment (if your storefront filters by category) ### Price Not Showing - Confirm the currency matches your storefront's default - Check that `amount` is in cents (4900 for 49.00) ### Images Not Loading - Ensure the file upload completed successfully - Check browser console for CORS errors - Verify the file storage is properly configured ## Next Steps - [Create Your First Order](./first-order) - Complete the checkout flow - [Platform Configuration](../platform-configuration/) - Customize your shop - [Product Module](../platform-configuration/modules/products) - Advanced product settings --- ## Quick Start # Quick Start Guide This guide will help you set up a complete e-commerce solution in about 5 minutes. ## What You'll Build By the end of this guide, you'll have a fully working web shop with your first product and first test order. ## Guide Structure This Quick Start guide is organized into the following sections: 1. **[Development Environment Setup](./setup-environment)** - Install required tools and prepare your system 2. **[Initialize Your Project](./run-local)** - Create and configure your full-stack project based on Unchained Engine and Unchained Storefront 3. **[Your First Product](./first-product)** - Create and publish your first product in the Admin UI 4. **[Your First Order](./first-order)** - Complete the checkout flow and create your first order ## Architecture Overview Unchained Engine follows a headless architecture with three main components: ```mermaid flowchart TD S[StorefrontNext.js] --> E[Unchained EngineGraphQL API] A[Admin UISPA] --> E E --> M[(MongoDB)] ``` - **Unchained Engine**: The core API server providing GraphQL endpoints - **Storefront**: Your customer-facing web application - **Admin UI**: Management interface for products, orders, and settings ## Quick Commands Reference ```bash # Create new Unchained Engine npm init @unchainedshop # Start development server npm run dev ``` ## Next Steps Ready to get started? Continue to [Development Environment Setup β†’](./setup-environment) ## Getting Help - πŸ“š [Full Documentation](/) - πŸ’¬ [GitHub Discussions](https://github.com/unchainedshop/unchained/discussions) - πŸ› [Report Issues](https://github.com/unchainedshop/unchained/issues) - πŸ“§ [Contact Support](mailto:support@unchained.shop) --- ## Start your first Unchained Project This guide walks you through creating and running a new project on localhost. [Alternatively, you can scaffold your first Unchained project with Railway and continue from there.](./run-railway.md) Using Railway has the benefit that you start with a deployed project including CI pipelines. From there, you can walk your way back by "ejecting" to your own GitHub repository. ## Project Structure Overview A typical Unchained project consists of two main sub-projects: ``` my-shop/ β”œβ”€β”€ engine/ # Unchained Engine (Backend API) β”‚ β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ package.json β”‚ └── .env └── storefront/ # Next.js Storefront (Frontend) β”œβ”€β”€ pages/ β”œβ”€β”€ components/ β”œβ”€β”€ package.json └── .env.local ``` ## Scaffold your project ### npm init To start, you can use our npm init helper: ```bash mkdir my-shop && cd my-shop npm init @unchainedshop ``` When running the init command, you'll be prompted with several questions: ```bash # 1. Select template type ? What type of template do you want β€Ί Full Stack E-Commerce <-- Select this Storefront Unchained Engine # 2. Enter project name (or press Enter for default) ? Name of project β€Ί my-shop # 3. Enter directory name (press Enter to use current directory) ? Directory name relative to current directory β€Ί . # 4. Initialize git repository (choose based on your preference) ? Do you want Initialize git? β€Ί no / yes ``` ### Start the Engine 1. **Install dependencies:** ```bash npm install ``` The install script will install the dependencies in both engine and storefront sub-directories. 2. **Start in development mode:** ```bash npm run dev ``` Both services will be started in parallel, the backend and the storefront. You should see output similar to: ``` [dev:engine] Server listening at http://[::]:4010 [dev:storefront] - Local: http://localhost:3000 ``` :::note - The exact output format may vary depending on your terminal - If port 3000 is already in use, the storefront will automatically use the next available port - You may see `.env not found` warnings - this is normal, as defaults are loaded from `.env.defaults` ::: **Access Points:** - **Admin UI**: http://localhost:4010 - **Storefront**: http://localhost:3000 - **GraphQL Playground**: http://localhost:4010/graphql ### Verify Engine Installation 1. **Configure your Backend** - Open http://localhost:4010 in your browser - Set up your administrator user. The built-in email preview will pop up with a verification linkβ€”it's not necessary to click it. - Go to the dashboard and complete the onboarding (essentials) To have a working checkout, you need: - 1 currency - 1 country with the default currency set - 1 language - 1 payment provider (use Invoice -> Invoice) - 1 delivery provider (use Shipping -> Manual) - 1 simple product in status published with at least one price setup in commerce. 2. **Verify Checkout on Storefront** - Open http://localhost:3000 in your browser - Scroll down, you should see your product - Add it to the cart and complete the payment process - At the end of the process, the built-in email preview should show the email confirmation ## Next Steps Your Unchained project is now initialized and running locally! Continue with the Quick Start guide: - **[Create Your First Product](./first-product)** - Learn how to create and publish products in the Admin UI - **[Create Your First Order](./first-order)** - Complete the checkout flow and see your first order Once you've completed the Quick Start, explore [Platform Configuration](../platform-configuration/) to customize your shop. ## Development Workflow / Troubleshooting ### Hot Reloading Both engine and storefront support hot reloading: - Engine: Changes to code automatically restart the server - Storefront: Changes immediately reflect in the browser ### Useful Commands **Engine Commands:** ```bash npm run dev # Start development server npm run build # Build for production npm run start # Start production server npm run lint # Run linter ``` **Storefront Commands:** ```bash npm run dev # Start development server npm run build # Build for production npm run start # Start production server npm run lint # Run linter ``` ### Engine Issues **Port Already in Use** ```bash # Find process using port 4010 lsof -i :4010 # Kill the process or use different port in .env ``` **MongoDB Connection Failed** - Ensure MongoDB is running by checking engine logs - Check connection string in dotenv files - Verify database permissions if custom connection string is used ### Storefront Issues **API Connection Failed** - Verify engine is running - Check `UNCHAINED_ENDPOINT` in `.env.local` - Look for CORS errors in browser console **Build Errors** - Clear Next.js cache: `rm -rf .next` - Reinstall dependencies: `rm -rf node_modules && npm install` --- ## Start your first Unchained Project on Railway ## Deploy on Railway Follow this button: [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/unchained?referralCode=ZXvOAF) ### E-Mail Setup When presented with this screen here, make sure you click on **Configure** for "unchained" in order to setup E-Mail: ![Railway Deployment Screen](../../src/assets/railway-deploy.png) To configure E-Mail, you need to specify `MAIL_URL` and `EMAIL_FROM`. If you skip setting these variables, your instance will not be able to send any mails at all. If you don't have any SMTP Server at hand, we recommend: - https://brevo.com (πŸ‡«πŸ‡·) - https://www.mailersend.com (πŸ‡±πŸ‡Ή) Those have a free plan that you can use for transactional e-mails. ### Verify Engine Installation After you have deployed the Template Railway will start a mongo:7 database service and build your project based on our basic Github Repositories: - [unchainedshop/unchained-app](https://github.com/unchainedshop/unchained-app) - [unchainedshop/unchained-storefront](https://github.com/unchainedshop/unchained-storefront) Once Railway communicates success, it looks like this: ![Deployment Ready](../../src/assets/railway-deployment-ready.png) To find the Unchained Admin UI URL of your deployment, open the "unchained" card. The deployment URL will propably look something like `unchained*.up.railway.app`. The Storefront URL of your depoyment can be found in "storefront" and looks something like `storefront*.up.railway.app`. 1. **Configure your Backend** - Open the Railway Deployment URL of the engine in your browser - Setup your administrator user - Go the dashboard and complete the onboarding (essentials) To have a working checkout, you need: - 1 currency - 1 country with the default currency set - 1 language - 1 payment provider (use Invoice -> Invoice) - 1 delivery provider (use Shipping -> Manual) - 1 simple product in status published with at least one price setup in commerce. 2. **Verify Checkout on Storefront** - Open the Railway Deployment URL of storefton in your browser - Scroll down, you should see your product - Add it to the cart and complete the payment process - At the end of the process, you should get an E-Mail Confirmation of the Order (if you have setup `MAIL_URL` and `EMAIL_URL` correctly) ## Eject Unchained is a code-first hackable platform. Code is law here and the out-of-the-box capabilities of unchained-app are limited, so you need to be able to do some hacking. In order to do that, you need to "eject" your backend (fork). Railway helps you with this, just go to settings of the unchained service, it will guide you through: ![Eject](../../src/assets/railway-eject.png) After cloning your own engine backend, you have to install dependencies: `npm install` Next you propably want to connect to the mongodb database service on railway by creating a `.env` file: ``` MONGO_URL=mongodb://mongo:***@*.proxy.rlwy.net:*** ``` You can find the correct MONGO_URL by opening the "MongoDB" service in your railway project and check the variable `MONGO_PUBLIC_URL`. If you skip this step, you need to do the setup once again for your localhost. At the end you can run the backend in development mode with: ``` npm run dev ``` Of course, same is true for the Storefront. Just eject it and start working on it. ## Next Steps Your Unchained project is now initialized and running locally and you should see your first confirmed order in the Admin UI. You can now turn to our next section "Platform Configuration" to find out how to configure and/or extend your project to your needs. ## Troublehsooting ### Adjusting Environment Variables on deployed Stack Please check [this guide of Railway](https://docs.railway.com/guides/variables) to see how environment variables can be adjusted on an existing Railway project. You can find [a list of basic environment variables for Unchained here](../platform-configuration/environment-variables.md) --- ## Development Environment Setup This guide will help you prepare your development environment for working with Unchained Engine. ## System Requirements ### Required Software #### Node.js (v22 or newer) Unchained Engine requires Node.js 22+ for optimal performance and compatibility. **Check your version:** ```bash node --version ``` **Install or update Node.js:** - Using [nvm](https://github.com/nvm-sh/nvm) (recommended): ```bash nvm install 22 nvm use 22 ``` - Direct download from [nodejs.org](https://nodejs.org/) #### MongoDB (v6.0 or newer) Unchained uses MongoDB as its primary database, but you do **not** have to install MongoDB! Unless you set an explicit connection string, Unchained will automatically download an appropriate version and run it locally for you, thanks to [MongoDB Memory Server](https://typegoose.github.io/mongodb-memory-server/). **Alternative: MongoDB Atlas (Cloud)** 1. Create a free account at [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) 2. Create a new cluster 3. Get your connection string and set it via `MONGO_URL` environment variable **Alternative: PostgreSQL via FerretDB** 1. Set up PostgreSQL 2. Start the FerretDB Docker container 3. Get your connection string and set it via the `MONGO_URL` environment variable 4. Set the `UNCHAINED_DOCUMENTDB_COMPAT_MODE` environment variable to `1` ## Next Steps Your environment is now ready! Continue to [Initialize Your Project β†’](./run-local) --- ## FAQ # Frequently Asked Questions ## General ### What is Unchained Engine? Unchained Engine is a headless, code-first e-commerce platform built with Node.js. It provides a GraphQL API that any frontend can consume, making it ideal for custom e-commerce solutions. ### What makes Unchained different from other e-commerce platforms? - **Code-first**: Configure through code, not control panels - **Headless**: Decoupled from any specific UI - **Plugin architecture**: Extensible via Director/Adapter pattern - **Open source**: EUPL-1.2 licensed - **MongoDB-based**: Flexible document storage ### What frontend frameworks can I use? Any framework that can make HTTP requests: - Next.js (most common) - React - Vue.js / Nuxt - Svelte / SvelteKit - Mobile apps (React Native, Flutter) ### Is Unchained suitable for large-scale deployments? Yes. Unchained is designed to scale horizontally and has been used in production by businesses processing significant order volumes. Key features for scale: - Stateless architecture - Distributed event system (Redis) - Background job processing - External file storage (S3) ## Setup & Installation ### What are the system requirements? - Node.js 22+ - MongoDB 6+ - 1GB+ RAM (2GB+ recommended for production) ### Do I need MongoDB Atlas or can I use local MongoDB? Both work. For development, local MongoDB is fine. For production, MongoDB Atlas is recommended for: - Automatic backups - High availability - Monitoring - Security ### Can I use PostgreSQL instead of MongoDB? No. Unchained is designed around MongoDB's document model. The flexible schema is a core architectural choice. ### How do I update Unchained? ```bash # Update all packages npm update @unchainedshop/platform # Check for breaking changes in MIGRATION.md ``` ## Development ### How do I extend the GraphQL schema? Use type extensions and custom resolvers: ```typescript const customTypeDefs = ` extend type Product { customField: String } `; const customResolvers = { Product: { customField: (product) => product.meta?.customField, }, }; await startPlatform({ modules: { customTypeDefs, customResolvers, }, }); ``` See [Extending GraphQL](../extend/graphql) for details. ### How do I add a custom payment provider? Create a payment adapter and register it: ```typescript const MyPaymentAdapter = { key: 'my-payment', label: 'My Payment', version: '1.0.0', typeSupported: (type) => type === 'CARD', actions: (params) => ({ // ... implement methods }), }; PaymentDirector.registerAdapter(MyPaymentAdapter); ``` See [Payment Plugins](../extend/order-fulfilment/fulfilment-plugins/payment). ### How do I handle webhooks? Add custom routes to your server: ```typescript const app = express(); app.post('/webhooks/stripe', async (req, res) => { // Handle Stripe webhook }); // Use with Unchained await startPlatform({ expressApp: app }); ``` ### How do I run background jobs? Use the Worker system: ```typescript // Schedule a job await modules.worker.addWork({ type: 'MY_JOB_TYPE', input: { /* data */ }, }); // Jobs are processed automatically ``` See [Worker](../extend/worker). ## Products & Catalog ### What product types are supported? - **Simple**: Basic products with price - **Configurable**: Products with variations (size, color) - **Bundle**: Collections of products - **Plan**: Subscription products - **Tokenized**: NFT/token-backed products ### How do I handle product variants? Use ConfigurableProduct with linked SimpleProducts: ```graphql mutation CreateConfigurableProduct { createProduct(product: { type: CONFIGURABLE_PRODUCT }) { _id } } ``` ```graphql mutation CreateVariant { createProduct(product: { type: SIMPLE_PRODUCT }) { _id } } ``` ```graphql mutation LinkVariant { addProductAssignment( proxyId: "configurable-id" productId: "simple-id" vectors: [ { key: "size", value: "M" } { key: "color", value: "blue" } ] ) { _id } } ``` ### How do I implement product search? Use the built-in search or integrate external search: ```graphql query SearchProducts { searchProducts(queryString: "t-shirt", filterQuery: [ { key: "category", value: "clothing" } ]) { products { _id } filteredProductsCount } } ``` See [Search and Filtering](../guides/search-and-filtering). ## Orders & Checkout ### How does the checkout flow work? 1. User authenticates (guest or registered) 2. Add products to cart 3. Set delivery provider and address 4. Set payment provider 5. Call `checkoutCart` 6. Payment webhook confirms payment 7. Order transitions to CONFIRMED See [Order Lifecycle](../concepts/order-lifecycle). ### Can customers checkout as guests? Yes: ```graphql mutation LoginAsGuest { loginAsGuest { _id tokenExpires } } ``` Guests can later convert to registered users without losing their order history. ### How do I implement subscriptions? Use Plan products with the Enrollment system: ```graphql mutation CreatePlanProduct { createProduct(product: { type: PLAN_PRODUCT }) { _id } } ``` When ordered, an Enrollment is created that generates recurring orders. ## Pricing ### How is pricing calculated? Through a chain of pricing adapters: 1. Base price 2. Discounts 3. Tax 4. Delivery fees 5. Payment fees See [Pricing System](../concepts/pricing-system). ### How do I implement custom pricing logic? Create a pricing adapter: ```typescript class MyPricingAdapter extends ProductPricingAdapter { static key = 'my-pricing'; static orderIndex = 10; async calculate() { this.result.addItem({ amount: 100, category: 'DISCOUNT', }); return super.calculate(); } } ProductPricingDirector.registerAdapter(MyPricingAdapter); ``` ### How do I handle multiple currencies? 1. Configure currencies in Admin UI 2. Set prices per currency or use exchange rates 3. Query with desired currency: ```graphql query ProductPrice { product(productId: "...") { ... on SimpleProduct { simulatedPrice(currencyCode: "EUR") { amount currencyCode } } } } ``` See [Multi-Currency Setup](../guides/multi-currency-setup). ## Internationalization ### How do I support multiple languages? 1. Configure languages in Admin UI 2. Add translations to entities 3. Query with Accept-Language header ```graphql # Headers: Accept-Language: de query { product(productId: "...") { texts { title # Returns German title } } } ``` See [Multi-Language Setup](../guides/multi-language-setup). ## Deployment ### Where can I host Unchained? - Railway (easiest) - Docker on any cloud (AWS, GCP, Azure) - Kubernetes - Vercel (for storefront, not engine) ### What's the recommended production setup? - Node.js 22+ on container platform - MongoDB Atlas for database - S3/MinIO for file storage - Redis for distributed events - CDN for static assets See platform-specific documentation for deployment guides. ### How do I handle database migrations? Migrations run automatically on startup when the Unchained platform boots. The migration system handles schema updates and data transformations between versions. ## Security ### How is authentication handled? - JWT tokens for API authentication - Session cookies optional - WebAuthn for passwordless auth - OIDC for external identity providers ### How do I implement role-based access? Use the built-in roles system: ```typescript const customRole = new Role('support'); customRole.allow('viewOrders', () => true); Roles.registerRole(customRole); // Assign to user await modules.users.updateRoles(userId, ['support']); ``` ### Is Unchained PCI compliant? Unchained doesn't store card data directly. Use payment providers (Stripe, PayPal) that handle PCI compliance. Payment adapter integrations use tokens, not card numbers. ## Troubleshooting ### Where are the logs? ```bash # Development npm run dev # Console output # Production docker logs -f container-name pm2 logs # Debug mode DEBUG=unchained:* npm run dev ``` ### How do I reset the database? ```bash # Drop database mongosh --eval "db.dropDatabase()" unchained # Restart server (will recreate collections) npm run dev ``` ### How do I get support? 1. Check [Troubleshooting](.) 2. Search [GitHub Issues](https://github.com/unchainedshop/unchained/issues) 3. Ask in [GitHub Discussions](https://github.com/unchainedshop/unchained/discussions) 4. For enterprise support, contact support@unchained.shop ## AI Integration For questions about the MCP server, Admin Copilot, or connecting AI agents to Unchained, see the [AI Integration FAQ](../ai-integration/ai-faq). --- ## Troubleshooting This guide covers common issues and their solutions when working with Unchained Engine. ## Quick Diagnostics ### Check Server Health ```bash # Test API endpoint curl http://localhost:4010/graphql \ -H "Content-Type: application/json" \ -d '{"query":"{ __typename }"}' # Expected response {"data":{"__typename":"Query"}} ``` ### Check Logs ```bash # Development npm run dev # Watch console output # Production (Docker) docker logs -f my-shop # Production (PM2) pm2 logs ``` ### Check MongoDB Connection ```bash # Test connection mongosh "mongodb://localhost:27017/unchained" --eval "db.adminCommand('ping')" # Check collections mongosh "mongodb://localhost:27017/unchained" --eval "db.getCollectionNames()" ``` ## Common Issues ### Server Won't Start #### Port Already in Use ``` Error: listen EADDRINUSE: address already in use :::4010 ``` **Solution:** ```bash # Find process using port lsof -i :4010 # Kill the process kill -9 # Or use a different port PORT=4011 npm run dev ``` #### MongoDB Connection Failed ``` MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017 ``` **Solutions:** 1. Start MongoDB: ```bash # macOS with Homebrew brew services start mongodb-community # Docker docker run -d -p 27017:27017 mongo:7 ``` 2. Check connection string in `.env`: ```bash MONGO_URL=mongodb://localhost:27017/unchained ``` 3. For MongoDB Atlas, ensure IP whitelist includes your IP #### Missing Environment Variables ``` Error: UNCHAINED_TOKEN_SECRET is required ``` **Solution:** Create `.env` file with required variables: ```bash UNCHAINED_TOKEN_SECRET=your-secret-at-least-32-characters ROOT_URL=http://localhost:4010 ``` ### Authentication Issues #### "Unauthorized" Error ```json {"errors":[{"message":"Unauthorized"}]} ``` **Solutions:** 1. Ensure Authorization header is set: ```http Authorization: Bearer ``` 2. Check token hasn't expired 3. Verify token secret matches between requests #### Guest Login Fails **Solutions:** 1. Check user module is properly initialized 2. Verify database is writable 3. Check for validation errors in logs ### Cart and Checkout #### "No Cart" / Cart is Empty **Solutions:** 1. Ensure user is authenticated: ```graphql mutation LoginAsGuest { loginAsGuest { _id tokenExpires } } ``` 2. Use the token in subsequent requests 3. Check cart was created: ```graphql query { me { cart { _id } } } ``` #### Checkout Fails ``` Error: No delivery provider set ``` **Solutions:** 1. Create delivery provider in Admin UI 2. Set delivery provider before checkout: ```graphql mutation SetDeliveryProvider { updateCart(deliveryProviderId: "...") { _id } } ``` 3. Verify provider is active #### Payment Not Processing **Solutions:** 1. Check payment provider configuration 2. Verify API keys are correct (not test keys in production) 3. Check webhook is configured 4. Look for errors in payment provider dashboard ### Products #### Product Not Visible **Solutions:** 1. Check product status is "Active": ```graphql query { product(productId: "...") { status } } ``` 2. Ensure product has at least one price: ```graphql query ProductWithPrice { product(productId: "...") { ... on SimpleProduct { simulatedPrice(currencyCode: "CHF") { amount currencyCode } } } } ``` 3. Verify product is assigned to an assortment (if filtering by category) #### Price Not Showing **Solutions:** 1. Check price exists for the currency: ```graphql mutation UpdateProductPricing { updateProductCommerce(productId: "...", commerce: { pricing: [{ currencyCode: "CHF" countryCode: "CH" amount: 4900 isTaxable: true isNetPrice: true }] }) { _id } } ``` 2. Verify currency is active 3. Check pricing adapters aren't filtering it out ### Admin UI #### Can't Access Admin UI **Solutions:** 1. Verify engine is running on correct port 2. Check CORS settings allow Admin UI origin 3. Clear browser cache/cookies #### Login Not Working **Solutions:** 1. Reset admin password via CLI or database 2. Check email verification isn't required 3. Verify user has admin role ### File Uploads #### Upload Fails **Solutions:** 1. Ensure a file storage plugin is imported in your entry file: ```typescript // GridFS (MongoDB built-in) // Or MinIO/S3 ``` 2. For MinIO/S3, verify credentials: ```bash MINIO_ENDPOINT=... MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... MINIO_BUCKET=... ``` 3. Check file size limits #### Images Not Loading **Solutions:** 1. Verify file URLs are accessible 2. Check CORS headers on storage 3. For signed URLs, ensure signature is valid ### Performance Issues #### Slow Queries Unchained Engine automatically creates indexes on commonly queried fields during startup. Adding custom indexes is only necessary when you've added custom fields to your schemas. **Solutions:** 1. Add indexes for custom fields: ```typescript // Only needed if you query by custom fields await db.collection('products').createIndex({ 'meta.customField': 1 }); ``` 2. Check for N+1 queries in resolvers 3. Enable query logging to identify slow queries #### Memory Issues **Solutions:** 1. Increase Node.js memory: ```bash NODE_OPTIONS="--max-old-space-size=4096" npm start ``` 2. Check for memory leaks 3. Monitor with: ```bash node --inspect lib/index.js ``` ### Email Issues #### Emails Not Sending **Solutions:** 1. Check MAIL_URL is configured: ```bash MAIL_URL=smtp://user:pass@smtp.example.com:587 ``` 2. Verify SMTP credentials 3. Check spam folder 4. Test with email preview (development only) #### Email Template Errors **Solutions:** 1. Check template syntax 2. Verify all required variables are passed 3. Look for errors in worker logs ## Debug Mode Enable verbose logging: ```bash # Enable debug logging DEBUG=unchained:* npm run dev # Specific modules DEBUG=unchained:core:* npm run dev DEBUG=unchained:api:* npm run dev ``` ## Getting Help ### Before Asking for Help 1. Check this troubleshooting guide 2. Search [GitHub Issues](https://github.com/unchainedshop/unchained/issues) 3. Review [GitHub Discussions](https://github.com/unchainedshop/unchained/discussions) ### Providing Information When reporting issues, include: 1. **Environment**: - Node.js version (`node --version`) - Unchained version (`npm list @unchainedshop/platform`) - Operating system 2. **Error message**: Full error with stack trace 3. **Steps to reproduce**: Minimal example 4. **Relevant configuration**: (sanitize secrets) 5. **Logs**: Recent log output ### Example Issue Report ````markdown ## Environment - Node.js: 22.0.0 - Unchained: 3.0.0 - OS: macOS 14.0 ## Description Checkout fails with "Payment provider not found" error ## Steps to Reproduce 1. Create cart 2. Add product 3. Set delivery provider 4. Call checkoutCart ## Error ``` Error: Payment provider not found for order xyz at PaymentDirector.resolve (...) ``` ## Configuration ```typescript await startPlatform({ // ... config }); ``` ## Expected Behavior Checkout should complete using default payment provider ```` ## Related Documentation - [FAQ](./faq) - Frequently asked questions