diff --git a/docs/content/deployment/_index.md b/docs/content/deployment/_index.md new file mode 100644 index 0000000000..5e3cb28e4a --- /dev/null +++ b/docs/content/deployment/_index.md @@ -0,0 +1,11 @@ +--- +title: "Deployment" +weight: 2 +showtoc: false +--- + +# Deployment Guides + +This section contains guides for deploying a Vendure application to production. + +We are planning to publish specific guides for popular platforms soon. For now, you can find platform-specific information in our [Deployment discussion category](https://github.com/vendure-ecommerce/vendure/discussions/categories/deployment). diff --git a/docs/content/deployment/deploying-admin-ui.md b/docs/content/deployment/deploying-admin-ui.md new file mode 100644 index 0000000000..60e879cce5 --- /dev/null +++ b/docs/content/deployment/deploying-admin-ui.md @@ -0,0 +1,80 @@ +--- +title: "Deploying the Admin UI" +showtoc: true +--- + +## Deploying the Admin UI + +If you have customized the Admin UI with extensions, you should [compile your extensions ahead-of-time as part of the deployment process]({{< relref "/docs/plugins/extending-the-admin-ui" >}}#compiling-as-a-deployment-step). + +### Deploying a stand-alone Admin UI + +Usually, the Admin UI is served from the Vendure server via the AdminUiPlugin. However, you may wish to deploy the Admin UI app elsewhere. Since it is just a static Angular app, it can be deployed to any static hosting service such as Vercel or Netlify. + +Here's an example script that can be run as part of your host's `build` command, which will generate a stand-alone app bundle and configure it to point to your remote server API. + +This example is for Vercel, and assumes: + +* A `BASE_HREF` environment variable to be set to `/` +* A public (output) directory set to `build/dist` +* A build command set to `npm run build` or `yarn build` +* A package.json like this: + ```json + { + "name": "standalone-admin-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "ts-node compile.ts" + }, + "devDependencies": { + "@vendure/ui-devkit": "^1.4.5", + "ts-node": "^10.2.1", + "typescript": "~4.3.5" + } + } + ``` + +```TypeScript +// compile.ts +import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; +import { DEFAULT_BASE_HREF } from '@vendure/ui-devkit/compiler/constants'; +import path from 'path'; +import { promises as fs } from 'fs'; + +/** + * Compiles the Admin UI. If the BASE_HREF is defined, use that. + * Otherwise, go back to the default admin route. + */ +compileUiExtensions({ + outputPath: path.join(__dirname, 'build'), + baseHref: process.env.BASE_HREF ?? DEFAULT_BASE_HREF, + extensions: [ + /* any UI extensions would go here, or leave empty */ + ], +}) + .compile?.() + .then(() => { + // If building for Vercel deployment, replace the config to make + // api calls to api.example.com instead of localhost. + if (process.env.VERCEL) { + console.log('Overwriting the vendure-ui-config.json for Vercel deployment.'); + return fs.writeFile( + path.join(__dirname, 'build', 'dist', 'vendure-ui-config.json'), + JSON.stringify({ + apiHost: 'https://api.example.com', + apiPort: '443', + adminApiPath: 'admin-api', + tokenMethod: 'cookie', + defaultLanguage: 'en', + availableLanguages: ['en', 'de'], + hideVendureBranding: false, + hideVersion: false, + }), + ); + } + }) + .then(() => { + process.exit(0); + }); +``` diff --git a/docs/content/deployment/getting-data-into-production.md b/docs/content/deployment/getting-data-into-production.md new file mode 100644 index 0000000000..3911233858 --- /dev/null +++ b/docs/content/deployment/getting-data-into-production.md @@ -0,0 +1,47 @@ +--- +title: "Getting data into production" +showtoc: true +weight: 4 +--- + +# Getting data into production + +Once you have set up your production deployment, you'll need some way to get your products and other data into the system. + +The main tasks will be: + +1. Creation of the database schema +2. Importing initial data like roles, tax rates, countries etc. +3. Importing catalog data like products, variants, options, facets +4. Importing other data used by your application + +## Creating the database schema + +The first item - creation of the schema - can be automatically handled by TypeORM's `synchronize` feature. Switching it on for the initial +run will automatically create the schema. This can be done by using an environment variable: + +```TypeScript {hl_lines=[5]} +export const config: VendureConfig = { + // ... + dbConnectionOptions: { + type: 'postgres', + synchronize: process.env.DB_SYNCHRONIZE, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + }, + // ... +}; +``` + +Set the `DB_SYNCHRONIZE` variable to `true` on first start, and then after the schema is created, set it to `false`. + +## Importing initial & catalog data + +Importing initial and catalog data can be handled by Vendure `populate()` helper function - see the [Importing Product Data guide]({{< relref "importing-product-data" >}}). + +## Importing other data + +Any kinds of data not covered by the `populate()` function can be imported using a custom script, which can use any Vendure service or service defined by your custom plugins to populate data in any way you like. See the [Stand-alone scripts guide]({{< relref "stand-alone-scripts" >}}). diff --git a/docs/content/deployment/horizontal-scaling.md b/docs/content/deployment/horizontal-scaling.md new file mode 100644 index 0000000000..70b53fe8f4 --- /dev/null +++ b/docs/content/deployment/horizontal-scaling.md @@ -0,0 +1,72 @@ +--- +title: "Horizontal scaling" +showtoc: true +weight: 2 +--- + +# Horizontal scaling + +"Horizontal scaling" refers to increasing the performance capacity of your application by running multiple instances. + +This type of scaling has two main advantages: + +1. It can enable increased throughput (requests/second) by distributing the incoming requests between multiple instances. +2. It can increase resilience because if a single instance fails, the other instances will still be able to service requests. + +As discussed in the [Server resource requirements guide]({{< relref "server-resource-requirements" >}}), horizontal scaling can be the most cost-effective way of deploying your Vendure server due to the single-threaded nature of Node.js. + +In Vendure, both the server and the worker can be scaled horizontally. Scaling the server will increase the throughput of the GraphQL APIs, whereas scaling the worker can increase the speed with which the job queue is processed by allowing more jobs to be run in parallel. + +## Multi-instance configuration + +In order to run Vendure in a multi-instance configuration, there are some important configuration changes you'll need to make. The key consideration in configuring Vendure for this scenario is to ensure that any persistent state is managed externally from the Node process, and is shared by all instances. Namely: + +* The JobQueue should be stored externally using the [DefaultJobQueuePlugin]({{< relref "default-job-queue-plugin" >}}) (which stores jobs in the database) or the [BullMQJobQueuePlugin]({{< relref "bull-mqjob-queue-plugin" >}}) (which stores jobs in Redis), or some other custom JobQueueStrategy. **Note:** the BullMQJobQueuePlugin is much more efficient than the DefaultJobQueuePlugin, and is recommended for production applications. +* A custom [SessionCacheStrategy]({{< relref "session-cache-strategy" >}}) must be used which stores the session cache externally (such as in the database or Redis), since the default strategy stores the cache in-memory and will cause inconsistencies in multi-instance setups. [Example Redis-based SessionCacheStrategy]({{< relref "session-cache-strategy" >}}) +* When using cookies to manage sessions, make sure all instances are using the _same_ cookie secret: + ```TypeScript + const config: VendureConfig = { + authOptions: { + cookieOptions: { + secret: 'some-secret' + } + } + } + ``` +* Channel and Zone data gets cached in-memory as this data is used in virtually every request. The cache time-to-live defaults to 30 seconds, which is probably fine for most cases, but it can be configured in the [EntityOptions]({{< relref "entity-options" >}}#channelcachettl). + +## Using Docker or Kubernetes + +One way of implementing horizontal scaling is to use Docker to wrap your Vendure server & worker in a container, which can then be run as multiple instances. + +Some hosting providers allow you to provide a Docker image and will then run multiple instances of that image. Kubernetes can also be used to manage multiple instances +of a Docker image. + +For a more complete guide, see the [Using Docker guide]({{< relref "using-docker" >}}). + +## Using PM2 + +[PM2](https://pm2.keymetrics.io/) is a process manager which will spawn multiple instances of your server or worker, as well as re-starting any instances that crash. PM2 can be used on VPS hosts to manage multiple instances of Vendure without needing Docker or Kubernetes. + +PM2 must be installed on your server: + +```sh +npm install pm2@latest -g +``` + +Your processes can then be run in [cluster mode](https://pm2.keymetrics.io/docs/usage/cluster-mode/) with the following command: + +```sh +pm2 start ./dist/index.js -i 4 +``` + +The above command will start a cluster of 4 instances. You can also instruct PM2 to use the maximum number of available CPUs with `-i max`. + +Note that if you are using pm2 inside a Docker container, you should use the `pm2-runtime` command: + +```Dockerfile +# ... your existing Dockerfile config +RUN npm install pm2 -g + +CMD ["pm2-runtime", "app.js", "-i", "max"] +``` diff --git a/docs/content/deployment/production-configuration/env-var-ui.webp b/docs/content/deployment/production-configuration/env-var-ui.webp new file mode 100644 index 0000000000..5dbf7389c1 Binary files /dev/null and b/docs/content/deployment/production-configuration/env-var-ui.webp differ diff --git a/docs/content/deployment/production-configuration/index.md b/docs/content/deployment/production-configuration/index.md new file mode 100644 index 0000000000..097ac9263e --- /dev/null +++ b/docs/content/deployment/production-configuration/index.md @@ -0,0 +1,116 @@ +--- +title: 'Production configuration' +showtoc: true +weight: 0 +--- + +# Production configuration + +This is a guide to the recommended configuration for a production Vendure application. + +## Environment variables + +Keep sensitive information or context-dependent settings in environment variables. In local development you can store the values in a `.env` file. For production, you should use the mechanism provided by your hosting platform to set the values for production. + +The default `@vendure/create` project scaffold makes use of environment variables already. For example: + +```TypeScript +const IS_DEV = process.env.APP_ENV === 'dev'; +``` + +The `APP_ENV` environment variable can then be set using the admin dashboard of your hosting provider: + +{{< figure src="./env-var-ui.webp" title="A typical UI for setting env vars" >}} + +If you are using [Docker or Kubernetes]({{< relref "using-docker" >}}), they include their own methods of setting environment variables. + +## Superadmin credentials + +Ensure you set the superadmin credentials to something other than the default of `superadmin:superadmin`. Use your hosting platform's environment variables to set a **strong** password for the Superadmin account. + +```TypeScript +import { VendureConfig } from '@vendure/core'; + +export const config: VendureConfig = { + authOptions: { + tokenMethod: ['bearer', 'cookie'], + superadminCredentials: { + identifier: process.env.SUPERADMIN_USERNAME, + password: process.env.SUPERADMIN_PASSWORD, + }, + }, + // ... +}; +``` + +## API hardening + +It is recommended that you install and configure the [HardenPlugin]({{< relref "harden-plugin" >}}) for all production deployments. This plugin locks down your schema (disabling introspection and field suggestions) and protects your Shop API against malicious queries that could otherwise overwhelm your server. + +Install the plugin: + +```sh +npm install @vendure/harden-plugin + +# or + +yarn add @vendure/harden-plugin +``` + +Then add it to your VendureConfig: + +```TypeScript +import { VendureConfig } from '@vendure/core'; +import { HardenPlugin } from '@vendure/harden-plugin'; + +const IS_DEV = process.env.APP_ENV === 'dev'; + +export const config: VendureConfig = { + // ... + plugins: [ + HardenPlugin.init({ + maxQueryComplexity: 500, + apiMode: IS_DEV ? 'dev' : 'prod', + }), + // ... + ] +}; +``` + +{{< alert primary >}} +For a detailed explanation of how to best configure this plugin, see the [HardenPlugin docs]({{< relref "harden-plugin" >}}). +{{< /alert >}} + +## ID Strategy + +By default, Vendure uses auto-increment integer IDs as entity primary keys. While easier to work with in development, sequential primary keys can leak information such as the number of orders or customers in the system. + +For this reason you should consider using the UuidIdStrategy for production. + +```TypeScript +import { UuidIdStrategy, VendureConfig } from '@vendure/core'; + +export const config: VendureConfig = { + entityIdStrategy: new UuidIdStrategy(), + // ... +} +``` + +Another option, if you wish to stick with integer IDs, is to create a custom [EntityIdStrategy]({{< relref "entity-id-strategy" >}}) which uses the `encodeId()` and `decodeId()` methods to obfuscate the sequential nature of the ID. + +## Database Timezone + +Vendure internally treats all dates & times as UTC. However, you may sometimes run into issues where dates are offset by some fixed amount of hours. E.g. you place an order at 17:00, but it shows up in the Admin UI as being placed at 19:00. Typically, this is caused by the timezone of your database not being set to UTC. + +You can check the timezone in **MySQL/MariaDB** by executing: + +```SQL +SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP); +``` +and you should expect to see `00:00:00`. + +In **Postgres**, you can execute: +```SQL +show timezone; +``` +and you should expect to see `UTC` or `Etc/UTC`. diff --git a/docs/content/deployment/server-resource-requirements.md b/docs/content/deployment/server-resource-requirements.md new file mode 100644 index 0000000000..1686004cb2 --- /dev/null +++ b/docs/content/deployment/server-resource-requirements.md @@ -0,0 +1,29 @@ +--- +title: 'Server resource requirements' +showtoc: true +weight: 1 +--- + +## Server resource requirements + +### RAM + +The Vendure server and worker process each use around 200-300MB of RAM when idle. This figure will increase under load. + +The total RAM required by a single instance of the server depends on your project size (the number of products, variants, customers, orders etc.) as well as expected load (the number of concurrent users you expect). As a rule, 512MB per process would be a practical minimum for a smaller project with low expected load. + +### CPU + +CPU resources are generally measured in "cores" or "vCPUs" (virtual CPUs) depending on the type of hosting. The exact relationship between vCPUs and physical CPU cores is out of the scope of this guide, but for our purposes we will use "CPU" to refer to both physical and virtual CPU resources. + +Because Node.js is single-threaded, a single instance of the Vendure server or worker will not be able to take advantage of multiple CPUs. For example, if you set up a server instance running with 4 CPUs, the server will only use 1 of those CPUs and the other 3 will be wasted. + +Therefore, when looking to optimize performance (for example, the number of requests that can be serviced per second), it makes sense to scale horizontally by running multiple instances of the Vendure server. See the [Horizontal Scaling guide]({{< relref "horizontal-scaling" >}}). + +## Load testing + +It is important to test whether your current server configuration will be able to handle the loads you expect when you go into production. There are numerous tools out there to help you load test your application, such as: + +- [k6](https://k6.io/) +- [Artillery](https://www.artillery.io/) +- [jMeter](https://jmeter.apache.org/) diff --git a/docs/content/deployment/using-docker.md b/docs/content/deployment/using-docker.md new file mode 100644 index 0000000000..b27ed9aa90 --- /dev/null +++ b/docs/content/deployment/using-docker.md @@ -0,0 +1,193 @@ +--- +title: "Using docker" +showtoc: true +weight: 3 +--- + +# Using Docker + +[Docker](https://docs.docker.com/) is a technology which allows you to run your Vendure application inside a [container](https://docs.docker.com/get-started/#what-is-a-container). +The default installation with `@vendure/create` includes a sample Dockerfile: + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY package.json ./ +COPY package-lock.json ./ +RUN npm install --production +COPY . . +RUN npm run build +``` + +This Dockerfile can then be built into an "image" using: + +```sh +docker build -t vendure . +``` + +This same image can be used to run both the Vendure server and the worker: + +```sh +# Run the server +docker run -dp 3000:3000 --name vendure-server vendure npm run start:server + +# Run the worker +docker run -dp 3000:3000 --name vendure-worker vendure npm run start:worker +``` + +Here is a breakdown of the command used above: + +- `docker run` - run the image we created with `docker build` +- `-dp 3000:3000` - the `-d` flag means to run in "detached" mode, so it runs in the background and does not take control of your terminal. `-p 3000:3000` means to expose port 3000 of the container (which is what Vendure listens on by default) as port 3000 on your host machine. +- `--name vendure-server` - we give the container a human-readable name. +- `vendure` - we are referencing the tag we set up during the build. +- `npm run start:server` - this last part is the actual command that should be run inside the container. + +## Docker Compose + +Managing multiple docker containers can be made easier using [Docker Compose](https://docs.docker.com/compose/). In the below example, we use +the same Dockerfile defined above, and we also define a Postgres database to connect to: + +```yaml +version: "3" +services: + server: + build: + context: . + dockerfile: Dockerfile + ports: + - 3000:3000 + command: ["npm", "run", "start:server"] + volumes: + - /usr/src/app + environment: + DB_HOST: database + DB_PORT: 5432 + DB_NAME: vendure + DB_USERNAME: postgres + DB_PASSWORD: password + worker: + build: + context: . + dockerfile: Dockerfile + command: ["npm", "run", "start:worker"] + volumes: + - /usr/src/app + environment: + DB_HOST: database + DB_PORT: 5432 + DB_NAME: vendure + DB_USERNAME: postgres + DB_PASSWORD: password + database: + image: postgres + volumes: + - /var/lib/postgresql/data + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: password + POSTGRES_DB: vendure +``` + +## Kubernetes + +[Kubernetes](https://kubernetes.io/) is used to manage multiple containerized applications. +This deployment starts the shop container we created above as both worker and server. + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vendure-shop +spec: + selector: + matchLabels: + app: vendure-shop + replicas: 1 + template: + metadata: + labels: + app: vendure-shop + spec: + containers: + - name: server + image: vendure-shop:latest + command: + - node + args: + - "dist/index.js" + env: + # your env config here + ports: + - containerPort: 3000 + + - name: worker + image: vendure-shop:latest + imagePullPolicy: Always + command: + - node + args: + - "dist/index-worker.js" + env: + # your env config here + ports: + - containerPort: 3000 +``` + +## Health/Readiness Checks + +If you wish to deploy with Kubernetes or some similar system, you can make use of the health check endpoints. + +### Server + +This is a regular REST route (note: _not_ GraphQL), available at `/health`. + +```text +REQUEST: GET http://localhost:3000/health +``` + +```json +{ + "status": "ok", + "info": { + "database": { + "status": "up" + } + }, + "error": {}, + "details": { + "database": { + "status": "up" + } + } +} +``` + +Health checks are built on the [Nestjs Terminus module](https://docs.nestjs.com/recipes/terminus). You can also add your own health checks by creating plugins that make use of the [HealthCheckRegistryService]({{< relref "health-check-registry-service" >}}). + +### Worker + +Although the worker is not designed as an HTTP server, it contains a minimal HTTP server specifically to support HTTP health checks. To enable this, you need to call the `startHealthCheckServer()` method after bootstrapping the worker: + +```TypeScript +bootstrapWorker(config) + .then(worker => worker.startJobQueue()) + .then(worker => worker.startHealthCheckServer({ port: 3020 })) + .catch(err => { + console.log(err); + }); +``` +This will make the `/health` endpoint available. When the worker instance is running, it will return the following: + +```text +REQUEST: GET http://localhost:3020/health +``` + +```json +{ + "status": "ok" +} +``` diff --git a/docs/content/developer-guide/deployment.md b/docs/content/developer-guide/deployment.md deleted file mode 100644 index 2101f374ac..0000000000 --- a/docs/content/developer-guide/deployment.md +++ /dev/null @@ -1,274 +0,0 @@ ---- -title: "Deployment" -showtoc: true ---- - -# Deploying a Vendure Application - -A Vendure application is essentially a Node.js application, and can be deployed to any environment that supports Node.js. - -The bare minimum requirements are: - -* A server with Node.js installed -* A database server (if using MySQL/Postgres) - -A typical pattern is to run the Vendure app on the server, e.g. at `http://localhost:3000` and then use [nginx as a reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) to direct requests from the Internet to the Vendure application. - -Here is a [general guide to setting up a production-ready server](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-18-04) for an app such as Vendure. - -{{< alert >}} -You can find more information & discussion about platform-specific deployments in our [GitHub Discussions Deployment category](https://github.com/vendure-ecommerce/vendure/discussions/categories/deployment). -{{< /alert >}} - -## Database Timezone - -Vendure internally treats all dates & times as UTC. However, you may sometimes run into issues where dates are offset by some fixed amount of hours. E.g. you place an order at 17:00, but it shows up in the Admin UI as being placed at 19:00. Typically, this is caused by the timezone of your database not being set to UTC. - -You can check the timezone in **MySQL/MariaDB** by executing: - -```SQL -SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP); -``` -and you should expect to see `00:00:00`. - -In **Postgres**, you can execute: -```SQL -show timezone; -``` -and you should expect to see `UTC` or `Etc/UTC`. - - -## Security Considerations - -For a production Vendure server, there are a few security-related points to consider when deploying: - -* Set the [Superadmin credentials]({{< relref "auth-options" >}}#superadmincredentials) to something other than the default. -* Disable introspection in the [ApiOptions]({{< relref "api-options" >}}#introspection) (this option is available in v1.5+). -* Consider taking steps to harden your GraphQL APIs against DOS attacks. Use the [ApiOptions]({{< relref "api-options" >}}) to set up appropriate Express middleware for things like [request timeouts](https://github.com/expressjs/express/issues/3330) and [rate limits](https://www.npmjs.com/package/express-rate-limit). A tool such as [graphql-query-complexity](https://github.com/slicknode/graphql-query-complexity) can be used to mitigate resource-intensive GraphQL queries. -* You may wish to restrict the Admin API to only be accessed from trusted IPs. This could be achieved for instance by configuring an nginx reverse proxy that sits in front of the Vendure server. -* By default, Vendure uses auto-increment integer IDs as entity primary keys. While easier to work with in development, sequential primary keys can leak information such as the number of orders or customers in the system. For this reason you should consider using the [UuidIdStrategy]({{< relref "entity-id-strategy" >}}#uuididstrategy) for production. - ```TypeScript - import { UuidIdStrategy, VendureConfig } from '@vendure/core'; - - export const config: VendureConfig = { - entityIdStrategy: new UuidIdStrategy(), - // ... - } - ``` -* Consider using [helmet](https://github.com/helmetjs/helmet) as middleware (add to the `apiOptions.middleware` array) to handle security-related headers. - -## Serverless / multi-instance deployments - -Vendure supports running in a serverless or multi-instance (horizontally scaled) environment. The key consideration in configuring Vendure for this scenario is to ensure that any persistent state is managed externally from the Node process, and is shared by all instances. Namely: - -* The JobQueue should be stored externally using the [DefaultJobQueuePlugin]({{< relref "default-job-queue-plugin" >}}) (which stores jobs in the database) or the [BullMQJobQueuePlugin]({{< relref "bull-mqjob-queue-plugin" >}}) (which stores jobs in Redis), or some other custom JobQueueStrategy. -* A custom [SessionCacheStrategy]({{< relref "session-cache-strategy" >}}) must be used which stores the session cache externally (such as in the database or Redis), since the default strategy stores the cache in-memory and will cause inconsistencies in multi-instance setups. [Example Redis-based SessionCacheStrategy]({{< relref "session-cache-strategy" >}}) -* When using cookies to manage sessions, make sure all instances are using the _same_ cookie secret: - ```TypeScript - const config: VendureConfig = { - authOptions: { - cookieOptions: { - secret: 'some-secret' - } - } - } - ``` -* Channel and Zone data gets cached in-memory as this data is used in virtually every request. The cache time-to-live defaults to 30 seconds, which is probably fine for most cases, but it can be configured in the [EntityOptions]({{< relref "entity-options" >}}#channelcachettl). - -## Health/Readiness Checks - -If you wish to deploy with Kubernetes or some similar system, you can make use of the health check endpoints. - -### Server - -This is a regular REST route (note: _not_ GraphQL), available at `/health`. - -```text -REQUEST: GET http://localhost:3000/health -``` - -```json -{ - "status": "ok", - "info": { - "database": { - "status": "up" - } - }, - "error": {}, - "details": { - "database": { - "status": "up" - } - } -} -``` - -Health checks are built on the [Nestjs Terminus module](https://docs.nestjs.com/recipes/terminus). You can also add your own health checks by creating plugins that make use of the [HealthCheckRegistryService]({{< relref "health-check-registry-service" >}}). - -### Worker - -Although the worker is not designed as an HTTP server, it contains a minimal HTTP server specifically to support HTTP health checks. To enable this, you need to call the `startHealthCheckServer()` method after bootstrapping the worker: - -```TypeScript -bootstrapWorker(config) - .then(worker => worker.startJobQueue()) - .then(worker => worker.startHealthCheckServer({ port: 3020 })) - .catch(err => { - console.log(err); - }); -``` -This will make the `/health` endpoint available. When the worker instance is running, it will return the following: - -```text -REQUEST: GET http://localhost:3020/health -``` - -```json -{ - "status": "ok" -} -``` -{{< alert >}} -**Note:** there is also an _internal_ health check mechanism for the worker, which does not uses HTTP. This is used by the server's own health check to verify whether at least one worker is running. It works by adding a `check-worker-health` job to the JobQueue and checking that it got processed. -{{< /alert >}} - -## Admin UI - -If you have customized the Admin UI with extensions, you should [compile your extensions ahead-of-time as part of the deployment process]({{< relref "/docs/plugins/extending-the-admin-ui" >}}#compiling-as-a-deployment-step). - -### Deploying a stand-alone Admin UI - -Usually, the Admin UI is served from the Vendure server via the AdminUiPlugin. However, you may wish to deploy the Admin UI app elsewhere. Since it is just a static Angular app, it can be deployed to any static hosting service such as Vercel or Netlify. - -Here's an example script that can be run as part of your host's `build` command, which will generate a stand-alone app bundle and configure it to point to your remote server API. - -This example is for Vercel, and assumes: - -* A `BASE_HREF` environment variable to be set to `/` -* A public (output) directory set to `build/dist` -* A build command set to `npm run build` or `yarn build` -* A package.json like this: - ```json - { - "name": "standalone-admin-ui", - "version": "0.1.0", - "private": true, - "scripts": { - "build": "ts-node compile.ts" - }, - "devDependencies": { - "@vendure/ui-devkit": "^1.4.5", - "ts-node": "^10.2.1", - "typescript": "~4.3.5" - } - } - ``` - -```TypeScript -// compile.ts -import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; -import { DEFAULT_BASE_HREF } from '@vendure/ui-devkit/compiler/constants'; -import path from 'path'; -import { promises as fs } from 'fs'; - -/** - * Compiles the Admin UI. If the BASE_HREF is defined, use that. - * Otherwise, go back to the default admin route. - */ -compileUiExtensions({ - outputPath: path.join(__dirname, 'build'), - baseHref: process.env.BASE_HREF ?? DEFAULT_BASE_HREF, - extensions: [ - /* any UI extensions would go here, or leave empty */ - ], -}) - .compile?.() - .then(() => { - // If building for Vercel deployment, replace the config to make - // api calls to api.example.com instead of localhost. - if (process.env.VERCEL) { - console.log('Overwriting the vendure-ui-config.json for Vercel deployment.'); - return fs.writeFile( - path.join(__dirname, 'build', 'dist', 'vendure-ui-config.json'), - JSON.stringify({ - apiHost: 'https://api.example.com', - apiPort: '443', - adminApiPath: 'admin-api', - tokenMethod: 'cookie', - defaultLanguage: 'en', - availableLanguages: ['en', 'de'], - hideVendureBranding: false, - hideVersion: false, - }), - ); - } - }) - .then(() => { - process.exit(0); - }); -``` - - -## Docker & Kubernetes - -For a production ready Vendure server running on Kubernetes you can use the following Dockerfile and Kubernetes configuration. - -### Docker - -Assuming a project which has been scaffolded using `@vendure/create`, create a -Dockerfile in the root directory that looks like this: - -```Dockerfile -FROM node:16 -WORKDIR /usr/src/app -COPY . . -RUN yarn install --production -RUN yarn build -``` - -Build your Docker container using `docker build -t vendure-shop:latest .` - -### Kubernetes Deployment - -This deployment starts the shop container we created above as both worker and server. - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: vendure-shop -spec: - selector: - matchLabels: - app: vendure-shop - replicas: 1 - template: - metadata: - labels: - app: vendure-shop - spec: - containers: - - name: server - image: vendure-shop:latest - command: - - node - args: - - "dist/index.js" - env: - # your env config here - ports: - - containerPort: 3000 - - - name: worker - image: vendure-shop:latest - imagePullPolicy: Always - command: - - node - args: - - "dist/index-worker.js" - env: - # your env config here - ports: - - containerPort: 3000 -``` diff --git a/docs/content/plugins/plugin-examples/adding-rest-endpoint.md b/docs/content/plugins/plugin-examples/adding-rest-endpoint.md index 88a2c28904..f4b1acc9fa 100644 --- a/docs/content/plugins/plugin-examples/adding-rest-endpoint.md +++ b/docs/content/plugins/plugin-examples/adding-rest-endpoint.md @@ -38,3 +38,23 @@ export class RestPlugin {} {{< /alert >}} Side note: since this uses no Vendure-specific metadata, it could also be written using the Nestjs `@Module()` decorator rather than the `@VendurePlugin()` decorator. + +## Controlling access to REST endpoints + +You can use the [Allow decorator]({{< relref "allow-decorator" >}}) to declare the permissions required to access a REST endpoint: + +```TypeScript {hl_lines=[8]} +import { Controller, Get } from '@nestjs/common'; +import { Allow, Permission, Ctx, ProductService, RequestContext } from '@vendure/core'; + +@Controller('products') +export class ProductsController { + constructor(private productService: ProductService) {} + + @Allow(Permission.ReadProduct) + @Get() + findAll(@Ctx() ctx: RequestContext) { + return this.productService.findAll(ctx); + } +} +``` diff --git a/packages/core/e2e/custom-fields.e2e-spec.ts b/packages/core/e2e/custom-fields.e2e-spec.ts index 44c09b62f9..f078c3fd8f 100644 --- a/packages/core/e2e/custom-fields.e2e-spec.ts +++ b/packages/core/e2e/custom-fields.e2e-spec.ts @@ -181,6 +181,7 @@ const customConfig = mergeConfig(testConfig(), { readonly: true, }, ], + OrderLine: [{ name: 'validateInt', type: 'int', min: 0, max: 10 }], } as CustomFields, }); @@ -687,6 +688,89 @@ describe('Custom fields', () => { } `); }); + + // https://github.com/vendure-ecommerce/vendure/issues/1953 + describe('validation of OrderLine custom fields', () => { + it('addItemToOrder', async () => { + try { + const { addItemToOrder } = await shopClient.query(gql` + mutation { + addItemToOrder( + productVariantId: 1 + quantity: 1 + customFields: { validateInt: 11 } + ) { + ... on Order { + id + } + } + } + `); + fail('Should have thrown'); + } catch (e) { + expect(e.message).toContain( + `The custom field 'validateInt' value [11] is greater than the maximum [10]`, + ); + } + + const { addItemToOrder: result } = await shopClient.query(gql` + mutation { + addItemToOrder(productVariantId: 1, quantity: 1, customFields: { validateInt: 9 }) { + ... on Order { + id + lines { + customFields { + validateInt + } + } + } + } + } + `); + + expect(result.lines[0].customFields).toEqual({ validateInt: 9 }); + }); + + it('adjustOrderLine', async () => { + try { + const { adjustOrderLine } = await shopClient.query(gql` + mutation { + adjustOrderLine( + orderLineId: "T_1" + quantity: 1 + customFields: { validateInt: 11 } + ) { + ... on Order { + id + } + } + } + `); + fail('Should have thrown'); + } catch (e) { + expect(e.message).toContain( + `The custom field 'validateInt' value [11] is greater than the maximum [10]`, + ); + } + + const { adjustOrderLine: result } = await shopClient.query(gql` + mutation { + adjustOrderLine(orderLineId: "T_1", quantity: 1, customFields: { validateInt: 2 }) { + ... on Order { + id + lines { + customFields { + validateInt + } + } + } + } + } + `); + + expect(result.lines[0].customFields).toEqual({ validateInt: 2 }); + }); + }); }); describe('public access', () => { diff --git a/packages/core/e2e/customer.e2e-spec.ts b/packages/core/e2e/customer.e2e-spec.ts index 0e4565562d..6f4fe3c283 100644 --- a/packages/core/e2e/customer.e2e-spec.ts +++ b/packages/core/e2e/customer.e2e-spec.ts @@ -37,7 +37,15 @@ import { UpdateCustomer, UpdateCustomerNote, } from './graphql/generated-e2e-admin-types'; -import { AddItemToOrder, UpdatedOrderFragment } from './graphql/generated-e2e-shop-types'; +import { + ActiveOrderCustomerFragment, + AddItemToOrder, + AddItemToOrderMutation, + AddItemToOrderMutationVariables, + SetCustomerForOrderMutation, + SetCustomerForOrderMutationVariables, + UpdatedOrderFragment, +} from './graphql/generated-e2e-shop-types'; import { CREATE_ADDRESS, CREATE_CUSTOMER, @@ -51,7 +59,7 @@ import { UPDATE_CUSTOMER, UPDATE_CUSTOMER_NOTE, } from './graphql/shared-definitions'; -import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions'; +import { ADD_ITEM_TO_ORDER, SET_CUSTOMER } from './graphql/shop-definitions'; import { assertThrowsWithMessage } from './utils/assert-throws-with-message'; // tslint:disable:no-non-null-assertion @@ -586,6 +594,41 @@ describe('Customer resolver', () => { expect(createCustomer.firstName).toBe('Reusing Email'); expect(createCustomer.user?.identifier).toBe(thirdCustomer.emailAddress); }); + + // https://github.com/vendure-ecommerce/vendure/issues/1960 + it('delete a guest Customer', async () => { + const orderErrorGuard: ErrorResultGuard = createErrorResultGuard( + input => !!input.lines, + ); + + await shopClient.asAnonymousUser(); + await shopClient.query( + ADD_ITEM_TO_ORDER, + { + productVariantId: 'T_1', + quantity: 1, + }, + ); + const { setCustomerForOrder } = await shopClient.query< + SetCustomerForOrderMutation, + SetCustomerForOrderMutationVariables + >(SET_CUSTOMER, { + input: { + firstName: 'Guest', + lastName: 'Customer', + emailAddress: 'guest@test.com', + }, + }); + + orderErrorGuard.assertSuccess(setCustomerForOrder); + + const result = await adminClient.query( + DELETE_CUSTOMER, + { id: setCustomerForOrder.customer!.id }, + ); + + expect(result.deleteCustomer).toEqual({ result: DeletionResult.DELETED }); + }); }); describe('customer notes', () => { diff --git a/packages/core/src/api/middleware/validate-custom-fields-interceptor.ts b/packages/core/src/api/middleware/validate-custom-fields-interceptor.ts index dda6211d03..7779fb6c2f 100644 --- a/packages/core/src/api/middleware/validate-custom-fields-interceptor.ts +++ b/packages/core/src/api/middleware/validate-custom-fields-interceptor.ts @@ -35,6 +35,7 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor { inputs.add(`Update${entityName}Input`); return inputs; }, new Set()); + this.inputsWithCustomFields.add('OrderLineCustomFieldsInput'); } async intercept(context: ExecutionContext, next: CallHandler) { @@ -77,7 +78,16 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor { if (variableValues) { const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2'); const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields]; - + if (typeName === 'OrderLineCustomFieldsInput') { + // special case needed to handle custom fields passed via addItemToOrder or adjustOrderLine + // mutations. + await this.validateCustomFieldsObject( + this.configService.customFields.OrderLine, + languageCode, + variableValues, + injector, + ); + } if (variableValues.customFields) { await this.validateCustomFieldsObject( customFieldConfig, diff --git a/packages/core/src/service/services/collection.service.ts b/packages/core/src/service/services/collection.service.ts index 536955d569..e66e7004b9 100644 --- a/packages/core/src/service/services/collection.service.ts +++ b/packages/core/src/service/services/collection.service.ts @@ -499,6 +499,16 @@ export class CollectionService implements OnModuleInit { for (const coll of [...descendants.reverse(), collection]) { const affectedVariantIds = await this.getCollectionProductVariantIds(coll); const deletedColl = new Collection(coll); + // To avoid performance issues on huge collections, we first delete the links + // between the product variants and the collection by chunks + const chunkedDeleteIds = this.chunkArray(affectedVariantIds, 500); + for (const chunkedDeleteId of chunkedDeleteIds) { + await this.connection.rawConnection + .createQueryBuilder() + .relation(Collection, 'productVariants') + .of(collection) + .remove(chunkedDeleteId); + } await this.connection.getRepository(ctx, Collection).remove(coll); this.eventBus.publish(new CollectionModificationEvent(ctx, deletedColl, affectedVariantIds)); } diff --git a/packages/core/src/service/services/customer.service.ts b/packages/core/src/service/services/customer.service.ts index 9ccf215a82..233c83bf02 100644 --- a/packages/core/src/service/services/customer.service.ts +++ b/packages/core/src/service/services/customer.service.ts @@ -786,7 +786,9 @@ export class CustomerService { .getRepository(ctx, Customer) .update({ id: customerId }, { deletedAt: new Date() }); // tslint:disable-next-line:no-non-null-assertion - await this.userService.softDelete(ctx, customer.user!.id); + if (customer.user) { + await this.userService.softDelete(ctx, customer.user.id); + } this.eventBus.publish(new CustomerEvent(ctx, customer, 'deleted', customerId)); return { result: DeletionResult.DELETED, diff --git a/packages/create/templates/Dockerfile.hbs b/packages/create/templates/Dockerfile.hbs index 7c24b9f7c1..11a28d3761 100644 --- a/packages/create/templates/Dockerfile.hbs +++ b/packages/create/templates/Dockerfile.hbs @@ -4,6 +4,6 @@ WORKDIR /usr/src/app COPY package.json ./ COPY {{#if useYarn}}yarn.lock{{else}}package-lock.json{{/if}} ./ -RUN {{#if useYarn}}yarn{{else}}npm install{{/if}} +RUN {{#if useYarn}}yarn{{else}}npm install{{/if}} --production COPY . . RUN {{#if useYarn}}yarn{{else}}npm run{{/if}} build diff --git a/packages/create/templates/readme.hbs b/packages/create/templates/readme.hbs index 93ec96cbe8..277cdcac36 100644 --- a/packages/create/templates/readme.hbs +++ b/packages/create/templates/readme.hbs @@ -55,7 +55,8 @@ We've included a sample [Dockerfile](./Dockerfile) which you can build with the docker build -t vendure . ``` -and then run it with: +This builds an image and tags it with the name "vendure". We can then run it with: + ``` # Run the server docker run -dp 3000:3000 -e "DB_HOST=host.docker.internal" --name vendure-server vendure npm run start:server @@ -64,6 +65,20 @@ docker run -dp 3000:3000 -e "DB_HOST=host.docker.internal" --name vendure-server docker run -dp 3000:3000 -e "DB_HOST=host.docker.internal" --name vendure-worker vendure npm run start:worker ``` +Here is a breakdown of the command used above: + +- `docker run` - run the image we created with `docker build` +- `-dp 3000:3000` - the `-d` flag means to run in "detached" mode, so it runs in the background and does not take +control of your terminal. `-p 3000:3000` means to expose port 3000 of the container (which is what Vendure listens +on by default) as port 3000 on your host machine. +- `-e "DB_HOST=host.docker.internal"` - the `-e` option allows you to define environment variables. In this case we +are setting the `DB_HOST` to point to a special DNS name that is created by Docker desktop which points to the IP of +the host machine. Note that `host.docker.internal` only exists in a Docker Desktop environment and thus should only be +used in development. +- `--name vendure-server` - we give the container a human-readable name. +- `vendure` - we are referencing the tag we set up during the build. +- `npm run start:server` - this last part is the actual command that should be run inside the container. + ### Docker compose We've included a sample [docker-compose.yml](./docker-compose.yml) file which demonstrates how the server, worker, and diff --git a/packages/email-plugin/src/dev-mailbox.ts b/packages/email-plugin/src/dev-mailbox.ts index 0c170b1122..05c96f928e 100644 --- a/packages/email-plugin/src/dev-mailbox.ts +++ b/packages/email-plugin/src/dev-mailbox.ts @@ -1,8 +1,7 @@ import { LanguageCode } from '@vendure/common/lib/generated-types'; import { Channel, RequestContext } from '@vendure/core'; -import express, { Router } from 'express'; +import { Request, Router } from 'express'; import fs from 'fs-extra'; -import http from 'http'; import path from 'path'; import { EmailEventHandler } from './event-handler'; @@ -19,7 +18,7 @@ export class DevMailbox { serve(options: EmailPluginDevModeOptions): Router { const { outputPath, handlers } = options; - const server = express.Router(); + const server = Router(); server.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../../dev-mailbox.html')); }); @@ -43,7 +42,7 @@ export class DevMailbox { try { await this.handleMockEventFn(handler, { ...handler.mockEvent, - ctx: this.createRequestContext(languageCode as LanguageCode), + ctx: this.createRequestContext(languageCode as LanguageCode, req), } as EventWithContext); res.send({ success: true }); } catch (e) { @@ -112,9 +111,10 @@ export class DevMailbox { return content; } - private createRequestContext(languageCode: LanguageCode): RequestContext { + private createRequestContext(languageCode: LanguageCode, req: Request): RequestContext { return new RequestContext({ languageCode, + req, apiType: 'admin', session: {} as any, isAuthorized: false, diff --git a/packages/job-queue-plugin/src/bullmq/plugin.ts b/packages/job-queue-plugin/src/bullmq/plugin.ts index e7fbfd463c..d678e2ca08 100644 --- a/packages/job-queue-plugin/src/bullmq/plugin.ts +++ b/packages/job-queue-plugin/src/bullmq/plugin.ts @@ -28,11 +28,13 @@ import { BullMQPluginOptions } from './types'; * * ## Installation * - * `yarn add \@vendure/job-queue-plugin bullmq` + * `yarn add \@vendure/job-queue-plugin bullmq@1` * * or * - * `npm install \@vendure/job-queue-plugin bullmq` + * `npm install \@vendure/job-queue-plugin bullmq@1` + * + * **Note:** The v1.x version of this plugin is designed to work with bullmq v1.x. * * @example * ```ts diff --git a/packages/payments-plugin/e2e/fixtures/e2e-products-minimal.csv b/packages/payments-plugin/e2e/fixtures/e2e-products-minimal.csv index ba0f94f1b8..54ecfa4856 100644 --- a/packages/payments-plugin/e2e/fixtures/e2e-products-minimal.csv +++ b/packages/payments-plugin/e2e/fixtures/e2e-products-minimal.csv @@ -1,5 +1,6 @@ -name , slug , description , assets , facets , optionGroups , optionValues , sku , price , taxCategory , stockOnHand , trackInventory , variantAssets , variantFacets -Laptop , laptop , "Now equipped with seventh-generation Intel Core processors, Laptop is snappier than ever. From daily tasks like launching apps and opening files to more advanced computing, you can power through your day thanks to faster SSDs and Turbo Boost processing up to 3.6GHz." , , category:electronics|category:computers , "screen size|RAM" , "13 inch|8GB" , L2201308 , 1299.00 , standard , 100 , false , , - , , , , , , "15 inch|8GB" , L2201508 , 1399.00 , standard , 100 , false , , - , , , , , , "13 inch|16GB" , L2201316 , 2199.00 , standard , 100 , false , , - , , , , , , "15 inch|16GB" , L2201516 , 2299.00 , standard , 100 , false , , +name ,slug ,description ,assets,facets ,optionGroups ,optionValues ,sku ,price ,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets +Laptop ,laptop ,"Now equipped with seventh-generation Intel Core processors, Laptop is snappier than ever. From daily tasks like launching apps and opening files to more advanced computing, you can power through your day thanks to faster SSDs and Turbo Boost processing up to 3.6GHz.", ,category:electronics|category:computers,"screen size|RAM","13 inch|8GB" ,L2201308 ,1299.00,standard ,100 ,false , , + , , , , , ,"15 inch|8GB" ,L2201508 ,1399.00,standard ,100 ,false , , + , , , , , ,"13 inch|16GB",L2201316 ,2199.00,standard ,100 ,false , , + , , , , , ,"15 inch|16GB",L2201516 ,2299.00,standard ,100 ,false , , +Pinelab stickers,stickers,"Very nice but crazy expensive stickers for testing Mollie VAT rounding errors" , ,category:computers , , ,pl-stickers,99.99 ,standard ,100 ,false , , diff --git a/packages/payments-plugin/e2e/mollie-dev-server.ts b/packages/payments-plugin/e2e/mollie-dev-server.ts index fa6e23a98f..25d1737029 100644 --- a/packages/payments-plugin/e2e/mollie-dev-server.ts +++ b/packages/payments-plugin/e2e/mollie-dev-server.ts @@ -5,7 +5,6 @@ import { Logger, LogLevel, mergeConfig, - Order, OrderService, RequestContext, } from '@vendure/core'; @@ -81,8 +80,8 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers'; // Prepare order for payment await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '1', - quantity: 2, + productVariantId: 'T_5', + quantity: 10, }); const ctx = new RequestContext({ apiType: 'admin', @@ -90,7 +89,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers'; authorizedAsOwnerOnly: false, channel: await server.app.get(ChannelService).getDefaultChannel() }); - await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, { + await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, { description: 'Negative test surcharge', listPrice: -20000, }); diff --git a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts index e7173f0ef5..002a415958 100644 --- a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts +++ b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts @@ -138,8 +138,8 @@ describe('Mollie payments', () => { it('Should prepare an order', async () => { await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test'); const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: 'T_1', - quantity: 2, + productVariantId: 'T_5', + quantity: 10, }); order = addItemToOrder as TestOrderFragmentFragment; // Add surcharge @@ -220,8 +220,9 @@ describe('Mollie payments', () => { expect(mollieRequest?.webhookUrl).toEqual( `${mockData.host}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, ); - expect(mollieRequest?.amount?.value).toBe('2927.60'); - expect(mollieRequest?.amount?.currency).toBeDefined(); + expect(mollieRequest?.amount?.value).toBe('1009.90'); + expect(mollieRequest?.amount?.currency).toBe('USD'); + expect(mollieRequest.lines[0].vatAmount.value).toEqual('199.98'); let totalLineAmount = 0; for (const line of mollieRequest.lines) { totalLineAmount += Number(line.totalAmount.value); @@ -307,8 +308,8 @@ describe('Mollie payments', () => { }) .reply(200, { status: 'pending', resource: 'payment' }); const refund = await refundOne(adminClient, order.lines[0].id, order.payments[0].id); - expect(mollieRequest?.amount.value).toBe('1558.80'); - expect(refund.total).toBe(155880); + expect(mollieRequest?.amount.value).toBe('119.99'); + expect(refund.total).toBe(11999); expect(refund.state).toBe('Settled'); }); diff --git a/packages/payments-plugin/src/mollie/mollie.helpers.ts b/packages/payments-plugin/src/mollie/mollie.helpers.ts index ed7e499e01..767a92e848 100644 --- a/packages/payments-plugin/src/mollie/mollie.helpers.ts +++ b/packages/payments-plugin/src/mollie/mollie.helpers.ts @@ -31,16 +31,19 @@ export function toMollieOrderLines(order: Order): CreateParameters['lines'] { const lines: CreateParameters['lines'] = order.lines.map(line => ({ name: line.productVariant.name, quantity: line.quantity, - unitPrice: toAmount(line.proratedUnitPriceWithTax, order.currencyCode), // totalAmount has to match unitPrice * quantity + unitPrice: toAmount(line.proratedLinePriceWithTax / line.quantity, order.currencyCode), // totalAmount has to match unitPrice * quantity totalAmount: toAmount(line.proratedLinePriceWithTax, order.currencyCode), vatRate: String(line.taxRate), - vatAmount: toAmount(line.lineTax, order.currencyCode), + vatAmount: toAmount( + calculateLineTaxAmount(line.taxRate, line.proratedLinePriceWithTax), + order.currencyCode + ), })); // Add shippingLines lines.push(...order.shippingLines.map(line => ({ name: line.shippingMethod?.name || 'Shipping', quantity: 1, - unitPrice: toAmount(line.priceWithTax, order.currencyCode), + unitPrice: toAmount(line.discountedPriceWithTax, order.currencyCode), totalAmount: toAmount(line.discountedPriceWithTax, order.currencyCode), vatRate: String(line.taxRate), vatAmount: toAmount(line.discountedPriceWithTax - line.discountedPrice, order.currencyCode), @@ -49,7 +52,7 @@ export function toMollieOrderLines(order: Order): CreateParameters['lines'] { lines.push(...order.surcharges.map(surcharge => ({ name: surcharge.description, quantity: 1, - unitPrice: toAmount(surcharge.price, order.currencyCode), + unitPrice: toAmount(surcharge.priceWithTax, order.currencyCode), totalAmount: toAmount(surcharge.priceWithTax, order.currencyCode), vatRate: String(surcharge.taxRate), vatAmount: toAmount(surcharge.priceWithTax - surcharge.price, order.currencyCode), @@ -67,6 +70,17 @@ export function toAmount(value: number, orderCurrency: string): Amount { }; } +/** + * Recalculate tax amount per order line instead of per unit for Mollie. + * Vendure calculates tax per unit, but Mollie expects the tax to be calculated per order line (the total of the quantities). + * See https://github.com/vendure-ecommerce/vendure/issues/1939#issuecomment-1362962133 for more information on the rounding issue. + */ +export function calculateLineTaxAmount(taxRate: number, orderLinePriceWithTax: number): number { + const taxMultiplier = taxRate / 100; + return orderLinePriceWithTax * (taxMultiplier / (1+taxMultiplier)); // I.E. €99,99 * (0,2 ÷ 1,2) with a 20% taxrate + +} + /** * Lookup one of Mollies allowed locales based on an orders countrycode or channel default. * If both lookups fail, resolve to en_US to prevent payment failure