Просмотр исходного кода

Merge branch 'minor' into major

Michael Bromley 3 лет назад
Родитель
Сommit
0dd9521715
26 измененных файлов с 540 добавлено и 154 удалено
  1. 22 0
      CHANGELOG.md
  2. 3 3
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  3. 25 15
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  4. 2 0
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html
  5. 3 0
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.scss
  6. 3 1
      packages/common/src/shared-utils.ts
  7. 21 19
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  8. 35 0
      packages/core/e2e/order-promotion.e2e-spec.ts
  9. 69 0
      packages/core/e2e/shop-order.e2e-spec.ts
  10. 5 22
      packages/core/src/api/resolvers/admin/collection.resolver.ts
  11. 24 1
      packages/core/src/api/resolvers/entity/collection-entity.resolver.ts
  12. 1 1
      packages/core/src/common/configurable-operation.ts
  13. 1 1
      packages/core/src/config/promotion/actions/order-fixed-discount-action.ts
  14. 1 0
      packages/core/src/i18n/messages/en.json
  15. 6 1
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  16. 1 0
      packages/core/src/service/index.ts
  17. 14 4
      packages/core/src/service/services/collection.service.ts
  18. 19 7
      packages/core/src/service/services/order.service.ts
  19. 13 23
      packages/core/src/service/services/product.service.ts
  20. 15 1
      packages/core/src/service/services/promotion.service.ts
  21. 127 25
      packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts
  22. 24 0
      packages/payments-plugin/src/mollie/mollie-shop-schema.ts
  23. 35 6
      packages/payments-plugin/src/mollie/mollie.plugin.ts
  24. 14 5
      packages/payments-plugin/src/mollie/mollie.resolver.ts
  25. 53 19
      packages/payments-plugin/src/mollie/mollie.service.ts
  26. 4 0
      packages/testing/src/test-server.ts

+ 22 - 0
CHANGELOG.md

@@ -1,3 +1,25 @@
+## <small>1.7.4 (2022-10-11)</small>
+
+
+#### Perf
+
+* **core** Improve performance when querying product by slug ([742ad36](https://github.com/vendure-ecommerce/vendure/commit/742ad36))
+
+#### Fixes
+
+* **admin-ui** Fix fix of ShippingMethod update error ([2367ab0](https://github.com/vendure-ecommerce/vendure/commit/2367ab0)), closes [#1800](https://github.com/vendure-ecommerce/vendure/issues/1800)
+* **admin-ui** Fix variant editing when 2 options have same name ([56948c8](https://github.com/vendure-ecommerce/vendure/commit/56948c8)), closes [#1813](https://github.com/vendure-ecommerce/vendure/issues/1813)
+* **admin-ui** Wrap long promotion condition/action names ([3eba1c8](https://github.com/vendure-ecommerce/vendure/commit/3eba1c8))
+* **common** Handle edge case in serializing null prototype objects ([02249fb](https://github.com/vendure-ecommerce/vendure/commit/02249fb))
+* **core** Add translation for 'channel-not-found' error ([f7c053f](https://github.com/vendure-ecommerce/vendure/commit/f7c053f))
+* **core** Do not allow negative total with orderFixedDiscount action ([a031956](https://github.com/vendure-ecommerce/vendure/commit/a031956)), closes [#1823](https://github.com/vendure-ecommerce/vendure/issues/1823)
+* **core** Export TranslatorService helper from core (#1826) ([50d5856](https://github.com/vendure-ecommerce/vendure/commit/50d5856)), closes [#1826](https://github.com/vendure-ecommerce/vendure/issues/1826)
+* **core** Fix default search handling of mysql binary operators ([c133cce](https://github.com/vendure-ecommerce/vendure/commit/c133cce)), closes [#1808](https://github.com/vendure-ecommerce/vendure/issues/1808)
+* **core** Fix race condition when updating order addresses in parallel ([d436ea9](https://github.com/vendure-ecommerce/vendure/commit/d436ea9))
+* **core** Improved error handling for malformed collection filters ([cab520b](https://github.com/vendure-ecommerce/vendure/commit/cab520b))
+* **core** Persist customField relations in PromotionService (#1822) ([40fdd80](https://github.com/vendure-ecommerce/vendure/commit/40fdd80)), closes [#1822](https://github.com/vendure-ecommerce/vendure/issues/1822)
+* **testing** Correctly apply beforeListen middleware on TestServer (#1802) ([c1db17e](https://github.com/vendure-ecommerce/vendure/commit/c1db17e)), closes [#1802](https://github.com/vendure-ecommerce/vendure/issues/1802)
+
 ## <small>1.7.3 (2022-09-24)</small>
 
 

+ 3 - 3
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html

@@ -10,7 +10,7 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<div *ngFor="let group of optionGroups" class="option-groups">
+<div *ngFor="let group of optionGroups; index as i" class="option-groups">
     <div class="name">
         <label>{{ 'catalog.option' | translate }}</label>
         <input clrInput [(ngModel)]="group.name" name="name" [readonly]="!group.isNew" />
@@ -22,8 +22,8 @@
             [options]="group.values"
             [groupName]="group.name"
             [disabled]="group.name === ''"
-            (add)="addOption(group.id, $event.name)"
-            (remove)="removeOption(group.id, $event)"
+            (add)="addOption(i, $event.name)"
+            (remove)="removeOption(i, $event)"
         ></vdr-option-value-input>
     </div>
     <div>

+ 25 - 15
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts

@@ -157,14 +157,17 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         }
     }
 
-    addOption(groupId: string, optionName: string) {
-        this.optionGroups.find(g => g.id === groupId)?.values.push({ name: optionName, locked: false });
-        this.generateVariants();
-        this.optionsChanged = true;
+    addOption(index: number, optionName: string) {
+        const group = this.optionGroups[index];
+        if (group) {
+            group.values.push({ name: optionName, locked: false });
+            this.generateVariants();
+            this.optionsChanged = true;
+        }
     }
 
-    removeOption(groupId: string, { id, name }: { id?: string; name: string }) {
-        const optionGroup = this.optionGroups.find(g => g.id === groupId);
+    removeOption(index: number, { id, name }: { id?: string; name: string }) {
+        const optionGroup = this.optionGroups[index];
         if (optionGroup) {
             if (!id) {
                 optionGroup.values = optionGroup.values.filter(v => v.name !== name);
@@ -442,15 +445,22 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             .reduce((flat, o) => [...flat, ...o], []);
         const variants = this.generatedVariants
             .filter(v => v.enabled && !v.existing)
-            .map(v => ({
-                price: v.price,
-                sku: v.sku,
-                stock: v.stock,
-                optionIds: v.options
-                    .map(name => options.find(o => o.name === name.name))
-                    .filter(notNullOrUndefined)
-                    .map(o => o.id),
-            }));
+            .map(v => {
+                const optionIds = groups.map((group, index) => {
+                    const option = group.options.find(o => o.name === v.options[index].name);
+                    if (option) {
+                        return option.id;
+                    } else {
+                        throw new Error(`Could not find a matching option for group ${group.name}`);
+                    }
+                });
+                return {
+                    price: v.price,
+                    sku: v.sku,
+                    stock: v.stock,
+                    optionIds,
+                };
+            });
         return this.productDetailService.createProductVariants(
             this.product,
             variants,

+ 2 - 0
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html

@@ -106,6 +106,7 @@
                             *ngFor="let condition of getAvailableConditions()"
                             type="button"
                             vdrDropdownItem
+                            class="item-wrap"
                             (click)="addCondition(condition)"
                         >
                             {{ condition.description }}
@@ -136,6 +137,7 @@
                             *ngFor="let action of getAvailableActions()"
                             type="button"
                             vdrDropdownItem
+                            class="item-wrap"
                             (click)="addAction(action)"
                         >
                             {{ action.description }}

+ 3 - 0
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.scss

@@ -0,0 +1,3 @@
+.item-wrap {
+    white-space: normal;
+}

+ 3 - 1
packages/common/src/shared-utils.ts

@@ -24,7 +24,9 @@ export function isObject(item: any): item is object {
 }
 
 export function isClassInstance(item: any): boolean {
-    return isObject(item) && item.constructor.name !== 'Object';
+    // Even if item is an object, it might not have a constructor as in the
+    // case when it is a null-prototype object, i.e. created using `Object.create(null)`.
+    return isObject(item) && item.constructor && item.constructor.name !== 'Object';
 }
 
 type NumericPropsOf<T> = {

+ 21 - 19
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -1685,36 +1685,38 @@ describe('Default search plugin', () => {
 
         // https://github.com/vendure-ecommerce/vendure/issues/1789
         describe('input escaping', () => {
-            it('correctly escapes "a & b"', async () => {
-                const result = await adminClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
+            function search(term: string) {
+                return adminClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                     SEARCH_PRODUCTS,
                     {
-                        input: {
-                            take: 10,
-                            term: 'laptop & camera',
-                        },
+                        input: { take: 10, term },
                     },
                     {
-                        languageCode: LanguageCode.de,
+                        languageCode: LanguageCode.en,
                     },
                 );
+            }
+            it('correctly escapes "a & b"', async () => {
+                const result = await search('laptop & camera');
                 expect(result.search.items).toBeDefined();
             });
+
             it('correctly escapes other special chars', async () => {
-                const result = await adminClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
-                    SEARCH_PRODUCTS,
-                    {
-                        input: {
-                            take: 10,
-                            term: 'a : b ? * (c) ! "foo"',
-                        },
-                    },
-                    {
-                        languageCode: LanguageCode.de,
-                    },
-                );
+                const result = await search('a : b ? * (c) ! "foo"');
                 expect(result.search.items).toBeDefined();
             });
+
+            it('correctly escapes mysql binary mode chars', async () => {
+                expect((await search('foo+')).search.items).toBeDefined();
+                expect((await search('foo-')).search.items).toBeDefined();
+                expect((await search('foo<')).search.items).toBeDefined();
+                expect((await search('foo>')).search.items).toBeDefined();
+                expect((await search('foo*')).search.items).toBeDefined();
+                expect((await search('foo~')).search.items).toBeDefined();
+                expect((await search('foo@bar')).search.items).toBeDefined();
+                expect((await search('foo + - *')).search.items).toBeDefined();
+                expect((await search('foo + - bar')).search.items).toBeDefined();
+            });
         });
     });
 });

+ 35 - 0
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -754,6 +754,41 @@ describe('Promotions applied to Orders', () => {
                 expect(applyCouponCode!.discounts[0].description).toBe('$10 discount on order');
                 expect(applyCouponCode!.totalWithTax).toBe(5000);
             });
+
+            it('does not result in negative total when shipping is included', async () => {
+                shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                const { addItemToOrder } = await shopClient.query<
+                    CodegenShop.AddItemToOrderMutation,
+                    CodegenShop.AddItemToOrderMutationVariables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: getVariantBySlug('item-100').id,
+                    quantity: 1,
+                });
+                orderResultGuard.assertSuccess(addItemToOrder);
+                expect(addItemToOrder!.totalWithTax).toBe(120);
+                expect(addItemToOrder!.discounts.length).toBe(0);
+
+                const { setOrderShippingMethod } = await shopClient.query<
+                    CodegenShop.SetShippingMethodMutation,
+                    CodegenShop.SetShippingMethodMutationVariables
+                >(SET_SHIPPING_METHOD, {
+                    id: 'T_1',
+                });
+                orderResultGuard.assertSuccess(setOrderShippingMethod);
+                expect(setOrderShippingMethod.totalWithTax).toBe(620);
+
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, {
+                    couponCode,
+                });
+                orderResultGuard.assertSuccess(applyCouponCode);
+                expect(applyCouponCode!.discounts.length).toBe(1);
+                expect(applyCouponCode!.discounts[0].description).toBe('$10 discount on order');
+                expect(applyCouponCode!.subTotalWithTax).toBe(0);
+                expect(applyCouponCode!.totalWithTax).toBe(500); // shipping price
+            });
         });
 
         describe('discountOnItemWithFacets', () => {

+ 69 - 0
packages/core/e2e/shop-order.e2e-spec.ts

@@ -2237,6 +2237,75 @@ describe('Shop orders', () => {
             expect(removeAllOrderLines.shippingWithTax).toBe(0);
         });
     });
+
+    describe('edge cases', () => {
+        it('calling setShippingMethod and setBillingMethod in parallel does not introduce race condition', async () => {
+            const shippingAddress: CreateAddressInput = {
+                fullName: 'name',
+                company: 'company',
+                streetLine1: '12 Shipping Street',
+                streetLine2: null,
+                city: 'foo',
+                province: 'bar',
+                postalCode: '123456',
+                countryCode: 'US',
+                phoneNumber: '4444444',
+            };
+            const billingAddress: CreateAddressInput = {
+                fullName: 'name',
+                company: 'company',
+                streetLine1: '22 Billing Avenue',
+                streetLine2: null,
+                city: 'foo',
+                province: 'bar',
+                postalCode: '123456',
+                countryCode: 'US',
+                phoneNumber: '4444444',
+            };
+
+            await Promise.all([
+                shopClient.query<SetBillingAddress.Mutation, SetBillingAddress.Variables>(
+                    SET_BILLING_ADDRESS,
+                    {
+                        input: billingAddress,
+                    },
+                ),
+                shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
+                    SET_SHIPPING_ADDRESS,
+                    {
+                        input: shippingAddress,
+                    },
+                ),
+            ]);
+
+            const { activeOrder } = await shopClient.query(gql`
+                query {
+                    activeOrder {
+                        shippingAddress {
+                            ...OrderAddress
+                        }
+                        billingAddress {
+                            ...OrderAddress
+                        }
+                    }
+                }
+                fragment OrderAddress on OrderAddress {
+                    fullName
+                    company
+                    streetLine1
+                    streetLine2
+                    city
+                    province
+                    postalCode
+                    countryCode
+                    phoneNumber
+                }
+            `);
+
+            expect(activeOrder.shippingAddress).toEqual(shippingAddress);
+            expect(activeOrder.billingAddress).toEqual(billingAddress);
+        });
+    });
 });
 
 const GET_ORDER_CUSTOM_FIELDS = gql`

+ 5 - 22
packages/core/src/api/resolvers/admin/collection.resolver.ts

@@ -57,10 +57,7 @@ export class CollectionResolver {
         })
         relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
-        return this.collectionService.findAll(ctx, args.options || undefined, relations).then(res => {
-            res.items.forEach(this.encodeFilters);
-            return res;
-        });
+        return this.collectionService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -85,8 +82,7 @@ export class CollectionResolver {
         } else {
             throw new UserInputError(`error.collection-id-or-slug-must-be-provided`);
         }
-
-        return this.encodeFilters(collection);
+        return collection;
     }
 
     @Query()
@@ -105,7 +101,7 @@ export class CollectionResolver {
     ): Promise<Translated<Collection>> {
         const { input } = args;
         this.configurableOperationCodec.decodeConfigurableOperationIds(CollectionFilter, input.filters);
-        return this.collectionService.create(ctx, input).then(this.encodeFilters);
+        return this.collectionService.create(ctx, input);
     }
 
     @Transaction()
@@ -117,7 +113,7 @@ export class CollectionResolver {
     ): Promise<Translated<Collection>> {
         const { input } = args;
         this.configurableOperationCodec.decodeConfigurableOperationIds(CollectionFilter, input.filters || []);
-        return this.collectionService.update(ctx, input).then(this.encodeFilters);
+        return this.collectionService.update(ctx, input);
     }
 
     @Transaction()
@@ -128,7 +124,7 @@ export class CollectionResolver {
         @Args() args: MutationMoveCollectionArgs,
     ): Promise<Translated<Collection>> {
         const { input } = args;
-        return this.collectionService.move(ctx, input).then(this.encodeFilters);
+        return this.collectionService.move(ctx, input);
     }
 
     @Transaction()
@@ -151,19 +147,6 @@ export class CollectionResolver {
         return Promise.all(args.ids.map(id => this.collectionService.delete(ctx, id)));
     }
 
-    /**
-     * Encodes any entity IDs used in the filter arguments.
-     */
-    private encodeFilters = <T extends Collection | undefined>(collection: T): T => {
-        if (collection) {
-            this.configurableOperationCodec.encodeConfigurableOperationIds(
-                CollectionFilter,
-                collection.filters,
-            );
-        }
-        return collection;
-    };
-
     @Transaction()
     @Mutation()
     @Allow(Permission.CreateCatalog, Permission.CreateCollection)

+ 24 - 1
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -1,14 +1,21 @@
+import { Logger } from '@nestjs/common';
 import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
-import { CollectionBreadcrumb, ProductVariantListOptions } from '@vendure/common/lib/generated-types';
+import {
+    CollectionBreadcrumb,
+    ConfigurableOperation,
+    ProductVariantListOptions,
+} from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { ListQueryOptions } from '../../../common/types/common-types';
 import { Translated } from '../../../common/types/locale-types';
+import { CollectionFilter } from '../../../config/index';
 import { Asset, Collection, Product, ProductVariant } from '../../../entity';
 import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { AssetService } from '../../../service/services/asset.service';
 import { CollectionService } from '../../../service/services/collection.service';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { ConfigurableOperationCodec } from '../../common/configurable-operation-codec';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
@@ -22,6 +29,7 @@ export class CollectionEntityResolver {
         private collectionService: CollectionService,
         private assetService: AssetService,
         private localeStringHydrator: LocaleStringHydrator,
+        private configurableOperationCodec: ConfigurableOperationCodec,
     ) {}
 
     @ResolveField()
@@ -118,4 +126,19 @@ export class CollectionEntityResolver {
     async assets(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise<Asset[] | undefined> {
         return this.assetService.getEntityAssets(ctx, collection);
     }
+
+    @ResolveField()
+    filters(@Ctx() ctx: RequestContext, @Parent() collection: Collection): ConfigurableOperation[] {
+        try {
+            return this.configurableOperationCodec.encodeConfigurableOperationIds(
+                CollectionFilter,
+                collection.filters,
+            );
+        } catch (e: any) {
+            Logger.error(
+                `Could not decode the collection filter arguments for "${collection.name}" (id: ${collection.id}). Error message: ${e.message}`,
+            );
+            return [];
+        }
+    }
 }

+ 1 - 1
packages/core/src/common/configurable-operation.ts

@@ -440,7 +440,7 @@ function coerceValueToType<T extends ConfigArgs>(
         try {
             return (JSON.parse(value) as string[]).map(v => coerceValueToType(v, type, false)) as any;
         } catch (err: any) {
-            throw new InternalServerError(err.message);
+            throw new InternalServerError(`Could not parse list value "${value}": ` + err.message);
         }
     }
     switch (type) {

+ 1 - 1
packages/core/src/config/promotion/actions/order-fixed-discount-action.ts

@@ -13,7 +13,7 @@ export const orderFixedDiscount = new PromotionOrderAction({
         },
     },
     execute(ctx, order, args) {
-        return -Math.min(args.discount, order.total);
+        return -Math.min(args.discount, order.subTotal);
     },
     description: [{ languageCode: LanguageCode.en, value: 'Discount order by fixed amount' }],
 });

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -9,6 +9,7 @@
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
+    "channel-not-found": "No Channel with the token \"{ token }\" could be found",
     "collections-cannot-be-removed-from-default-channel": "Collections cannot be removed from the default Channel",
     "collection-id-or-slug-must-be-provided": "Either the Collection id or slug must be provided",
     "collection-id-slug-mismatch": "The provided id and slug refer to different Collections",

+ 6 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -149,7 +149,12 @@ export class MysqlSearchStrategy implements SearchStrategy {
             input;
 
         if (term && term.length > this.minTermLength) {
-            const safeTerm = term.replace(/"/g, '');
+            const safeTerm = term
+                .replace(/"/g, '')
+                .replace(/@/g, ' ')
+                .trim()
+                .replace(/[+\-*~<>]/g, ' ')
+                .trim();
             const termScoreQuery = this.connection
                 .getRepository(ctx, SearchIndexItem)
                 .createQueryBuilder('si_inner')

+ 1 - 0
packages/core/src/service/index.ts

@@ -17,6 +17,7 @@ export * from './helpers/product-price-applicator/product-price-applicator';
 export * from './helpers/refund-state-machine/refund-state';
 export * from './helpers/request-context/request-context.service';
 export * from './helpers/translatable-saver/translatable-saver';
+export * from './helpers/translator/translator.service';
 export * from './helpers/utils/patch-entity';
 export * from './helpers/utils/translate-entity';
 export * from './helpers/verification-token-generator/verification-token-generator';

+ 14 - 4
packages/core/src/service/services/collection.service.ts

@@ -124,10 +124,20 @@ export class CollectionService implements OnModuleInit {
                     }
                     completed++;
                     if (collection) {
-                        const affectedVariantIds = await this.applyCollectionFiltersInternal(
-                            collection,
-                            job.data.applyToChangedVariantsOnly,
-                        );
+                        let affectedVariantIds: ID[] = [];
+                        try {
+                            affectedVariantIds = await this.applyCollectionFiltersInternal(
+                                collection,
+                                job.data.applyToChangedVariantsOnly,
+                            );
+                        } catch (e) {
+                            const translatedCollection = await this.translator.translate(collection, ctx);
+                            Logger.error(
+                                `An error occurred when processing the filters for the collection "${translatedCollection.name}" (id: ${collection.id})`,
+                            );
+                            Logger.error(e.message);
+                            continue;
+                        }
                         job.setProgress(Math.ceil((completed / job.data.collectionIds.length) * 100));
                         this.eventBus.publish(
                             new CollectionModificationEvent(ctx, collection, affectedVariantIds),

+ 19 - 7
packages/core/src/service/services/order.service.ts

@@ -751,10 +751,16 @@ export class OrderService {
     async setShippingAddress(ctx: RequestContext, orderId: ID, input: CreateAddressInput): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
         const country = await this.countryService.findOneByCode(ctx, input.countryCode);
-        order.shippingAddress = { ...input, countryCode: input.countryCode, country: country.name };
-        await this.connection.getRepository(ctx, Order).save(order);
+        const shippingAddress = { ...input, countryCode: input.countryCode, country: country.name };
+        await this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .update(Order)
+            .set({ shippingAddress })
+            .where('id = :id', { id: order.id });
+        order.shippingAddress = shippingAddress;
         // Since a changed ShippingAddress could alter the activeTaxZone,
-        // we will remove any cached activeTaxZone so it can be re-calculated
+        // we will remove any cached activeTaxZone, so it can be re-calculated
         // as needed.
         this.requestCache.set(ctx, 'activeTaxZone', undefined);
         return this.applyPriceAdjustments(ctx, order, order.lines);
@@ -767,10 +773,16 @@ export class OrderService {
     async setBillingAddress(ctx: RequestContext, orderId: ID, input: CreateAddressInput): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
         const country = await this.countryService.findOneByCode(ctx, input.countryCode);
-        order.billingAddress = { ...input, countryCode: input.countryCode, country: country.name };
-        await this.connection.getRepository(ctx, Order).save(order);
-        // Since a changed ShippingAddress could alter the activeTaxZone,
-        // we will remove any cached activeTaxZone so it can be re-calculated
+        const billingAddress = { ...input, countryCode: input.countryCode, country: country.name };
+        await this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .update(Order)
+            .set({ billingAddress })
+            .where('id = :id', { id: order.id });
+        order.billingAddress = billingAddress;
+        // Since a changed BillingAddress could alter the activeTaxZone,
+        // we will remove any cached activeTaxZone, so it can be re-calculated
         // as needed.
         this.requestCache.set(ctx, 'activeTaxZone', undefined);
         return this.applyPriceAdjustments(ctx, order, order.lines);

+ 13 - 23
packages/core/src/service/services/product.service.ts

@@ -174,42 +174,32 @@ export class ProductService {
         relations?: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
         const qb = this.connection.getRepository(ctx, Product).createQueryBuilder('product');
-        const effectiveRelations = removeCustomFieldsWithEagerRelations(
-            qb,
-            relations ? [...new Set(this.relations.concat(relations))] : this.relations,
-        );
-        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
-            relations: effectiveRelations,
-        });
-        // tslint:disable-next-line:no-non-null-assertion
-        FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         const translationQb = this.connection
             .getRepository(ctx, ProductTranslation)
             .createQueryBuilder('_product_translation')
             .select('_product_translation.baseId')
             .andWhere('_product_translation.slug = :slug', { slug });
 
-        const translationsAlias = relations?.includes('translations' as any)
-            ? 'product__translations'
-            : 'product_translations';
-        qb.leftJoin('product.channels', 'channel')
+        qb.leftJoin('product.translations', 'translation')
+            .andWhere('product.deletedAt IS NULL')
             .andWhere('product.id IN (' + translationQb.getQuery() + ')')
             .setParameters(translationQb.getParameters())
-            .andWhere('product.deletedAt IS NULL')
-            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+            .select('product.id', 'id')
             .addSelect(
                 // tslint:disable-next-line:max-line-length
-                `CASE ${translationsAlias}.languageCode WHEN '${ctx.languageCode}' THEN 2 WHEN '${ctx.channel.defaultLanguageCode}' THEN 1 ELSE 0 END`,
+                `CASE translation.languageCode WHEN '${ctx.languageCode}' THEN 2 WHEN '${ctx.channel.defaultLanguageCode}' THEN 1 ELSE 0 END`,
                 'sort_order',
             )
             .orderBy('sort_order', 'DESC');
-        return qb
-            .getOne()
-            .then(product =>
-                product
-                    ? this.translator.translate(product, ctx, ['facetValues', ['facetValues', 'facet']])
-                    : undefined,
-            );
+        // We use getRawOne here to simply get the ID as efficiently as possible,
+        // which we then pass to the regular findOne() method which will handle
+        // all the joins etc.
+        const result = await qb.getRawOne();
+        if (result) {
+            return this.findOne(ctx, result.id, relations);
+        } else {
+            return undefined;
+        }
     }
 
     async create(ctx: RequestContext, input: CreateProductInput): Promise<Translated<Product>> {

+ 15 - 1
packages/core/src/service/services/promotion.service.ts

@@ -38,6 +38,7 @@ import { Promotion } from '../../entity/promotion/promotion.entity';
 import { EventBus } from '../../event-bus';
 import { PromotionEvent } from '../../event-bus/events/promotion-event';
 import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { patchEntity } from '../helpers/utils/patch-entity';
@@ -61,6 +62,7 @@ export class PromotionService {
         private channelService: ChannelService,
         private listQueryBuilder: ListQueryBuilder,
         private configArgService: ConfigArgService,
+        private customFieldRelationService: CustomFieldRelationService,
         private eventBus: EventBus,
     ) {
         this.availableConditions = this.configService.promotionOptions.promotionConditions || [];
@@ -130,7 +132,13 @@ export class PromotionService {
         }
         await this.channelService.assignToCurrentChannel(promotion, ctx);
         const newPromotion = await this.connection.getRepository(ctx, Promotion).save(promotion);
-        this.eventBus.publish(new PromotionEvent(ctx, newPromotion, 'created', input));
+        const promotionWithRelations = await this.customFieldRelationService.updateRelations(
+            ctx,
+            Promotion,
+            input,
+            newPromotion,
+        );
+        this.eventBus.publish(new PromotionEvent(ctx, promotionWithRelations, 'created', input));
         return assertFound(this.findOne(ctx, newPromotion.id));
     }
 
@@ -157,6 +165,12 @@ export class PromotionService {
         }
         promotion.priorityScore = this.calculatePriorityScore(input);
         await this.connection.getRepository(ctx, Promotion).save(updatedPromotion, { reload: false });
+        await this.customFieldRelationService.updateRelations(
+            ctx,
+            Promotion,
+            input,
+            updatedPromotion,
+        );
         this.eventBus.publish(new PromotionEvent(ctx, promotion, 'updated', input));
         return assertFound(this.findOne(ctx, updatedPromotion.id));
     }

+ 127 - 25
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -1,12 +1,7 @@
 /* tslint:disable:no-non-null-assertion */
 import { PaymentStatus } from '@mollie/api-client';
-import { DefaultLogger, LogLevel, mergeConfig } from '@vendure/core';
-import {
-    createTestEnvironment,
-    E2E_DEFAULT_CHANNEL_TOKEN,
-    SimpleGraphQLClient,
-    TestServer,
-} from '@vendure/testing';
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN, SimpleGraphQLClient, TestServer } from '@vendure/testing';
 import gql from 'graphql-tag';
 import nock from 'nock';
 import fetch from 'node-fetch';
@@ -50,13 +45,35 @@ export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
     }
 `;
 
+export const GET_MOLLIE_PAYMENT_METHODS = gql`
+    query molliePaymentMethods($input: MolliePaymentMethodsInput!) {
+        molliePaymentMethods(input: $input) {
+            id
+            code
+            description
+            minimumAmount {
+                value
+                currency
+            }
+            maximumAmount {
+                value
+                currency
+            }
+            image {
+                size1x
+                size2x
+                svg
+            }
+        }
+    }`;
+
 describe('Mollie payments', () => {
     const mockData = {
         host: 'https://my-vendure.io',
         redirectUrl: 'https://my-storefront/order',
         apiKey: 'myApiKey',
         methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
-        mollieResponse: {
+        molliePaymentResponse: {
             id: 'tr_mockId',
             _links: {
                 checkout: {
@@ -72,6 +89,46 @@ describe('Mollie payments', () => {
             authorizedAt: new Date(),
             paidAt: new Date(),
         },
+        molliePaymentMethodsResponse:{
+            count: 1,
+            _embedded: {
+                methods: [
+                    {
+                        resource: 'method',
+                        id: 'ideal',
+                        description: 'iDEAL',
+                        minimumAmount: {
+                            value: '0.01',
+                            currency: 'EUR'
+                        },
+                        maximumAmount: {
+                            value: '50000.00',
+                            currency: 'EUR'
+                        },
+                        image: {
+                            size1x: 'https://www.mollie.com/external/icons/payment-methods/ideal.png',
+                            size2x: 'https://www.mollie.com/external/icons/payment-methods/ideal%402x.png',
+                            svg: 'https://www.mollie.com/external/icons/payment-methods/ideal.svg'
+                        },
+                        _links: {
+                            self: {
+                                href: 'https://api.mollie.com/v2/methods/ideal',
+                                type: 'application/hal+json'
+                            }
+                        }
+                    }]
+            },
+            _links: {
+                self: {
+                    href: 'https://api.mollie.com/v2/methods',
+                    type: 'application/hal+json'
+                },
+                documentation: {
+                    href: 'https://docs.mollie.com/reference/v2/methods-api/list-methods',
+                    type: 'text/html'
+                }
+            }
+        }
     };
     let shopClient: SimpleGraphQLClient;
     let adminClient: SimpleGraphQLClient;
@@ -159,14 +216,26 @@ describe('Mollie payments', () => {
         expect(result.errorCode).toBe('ORDER_PAYMENT_STATE_ERROR');
     });
 
-    it('Should get payment url', async () => {
-        let mollieRequest: any;
+    it('Should fail to create payment intent with invalid Mollie method', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+        const { createMolliePaymentIntent: result } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                molliePaymentMethodCode: 'invalid'
+            },
+        });
+        expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
+    });
+
+    it('Should get payment url without Mollie method', async () => {
+        let mollieRequest;
         nock('https://api.mollie.com/')
             .post(/.*/, body => {
                 mollieRequest = body;
                 return true;
             })
-            .reply(200, mockData.mollieResponse);
+            .reply(200, mockData.molliePaymentResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await setShipping(shopClient);
         const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
@@ -186,17 +255,36 @@ describe('Mollie payments', () => {
         expect(mollieRequest?.amount?.currency).toBeDefined();
     });
 
+    it('Should get payment url with Mollie method', async () => {
+        let mollieRequest;
+        nock('https://api.mollie.com/')
+            .post(/.*/, body => {
+                mollieRequest = body;
+                return true;
+            })
+            .reply(200, mockData.molliePaymentResponse);
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                molliePaymentMethodCode: 'ideal',
+            },
+        });
+        expect(createMolliePaymentIntent).toEqual({ url: 'https://www.mollie.com/payscreen/select-method/mock-payment' });
+    });
+
     it('Should settle payment for order', async () => {
         nock('https://api.mollie.com/')
             .get(/.*/)
             .reply(200, {
-                ...mockData.mollieResponse,
+                ...mockData.molliePaymentResponse,
                 status: PaymentStatus.paid,
                 metadata: { orderCode: order.code },
             });
         await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
             method: 'post',
-            body: JSON.stringify({ id: mockData.mollieResponse.id }),
+            body: JSON.stringify({ id: mockData.molliePaymentResponse.id }),
             headers: { 'Content-Type': 'application/json' },
         });
         const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
@@ -211,18 +299,14 @@ describe('Mollie payments', () => {
     });
 
     it('Should have Mollie metadata on payment', async () => {
-        const {
-            order: {
-                payments: [{ metadata }],
-            },
-        } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
-        expect(metadata.mode).toBe(mockData.mollieResponse.mode);
-        expect(metadata.method).toBe(mockData.mollieResponse.method);
-        expect(metadata.profileId).toBe(mockData.mollieResponse.profileId);
-        expect(metadata.settlementAmount).toBe(mockData.mollieResponse.settlementAmount);
-        expect(metadata.customerId).toBe(mockData.mollieResponse.customerId);
-        expect(metadata.authorizedAt).toEqual(mockData.mollieResponse.authorizedAt.toISOString());
-        expect(metadata.paidAt).toEqual(mockData.mollieResponse.paidAt.toISOString());
+        const { order: { payments: [{ metadata }] } } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
+        expect(metadata.mode).toBe(mockData.molliePaymentResponse.mode);
+        expect(metadata.method).toBe(mockData.molliePaymentResponse.method);
+        expect(metadata.profileId).toBe(mockData.molliePaymentResponse.profileId);
+        expect(metadata.settlementAmount).toBe(mockData.molliePaymentResponse.settlementAmount);
+        expect(metadata.customerId).toBe(mockData.molliePaymentResponse.customerId);
+        expect(metadata.authorizedAt).toEqual(mockData.molliePaymentResponse.authorizedAt.toISOString());
+        expect(metadata.paidAt).toEqual(mockData.molliePaymentResponse.paidAt.toISOString());
     });
 
     it('Should fail to refund', async () => {
@@ -250,4 +334,22 @@ describe('Mollie payments', () => {
         expect(refund.total).toBe(155880);
         expect(refund.state).toBe('Settled');
     });
+
+    it('Should get available paymentMethods', async () => {
+        nock('https://api.mollie.com/')
+            .get(/.*/)
+            .reply(200, mockData.molliePaymentMethodsResponse);
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        const method = molliePaymentMethods[0];
+        expect(method.code).toEqual('ideal');
+        expect(method.minimumAmount).toBeDefined()
+        expect(method.maximumAmount).toBeDefined()
+        expect(method.image).toBeDefined()
+    });
+
 });

+ 24 - 0
packages/payments-plugin/src/mollie/mollie-shop-schema.ts

@@ -5,14 +5,38 @@ export const shopSchema = gql`
         errorCode: ErrorCode!
         message: String!
     }
+    type MollieAmount {
+        value: String
+        currency: String
+    }
+    type MolliePaymentMethodImages {
+        size1x: String
+        size2x: String
+        svg: String
+    }
+    type MolliePaymentMethod {
+        id: ID!
+        code: String!
+        description: String
+        minimumAmount: MollieAmount
+        maximumAmount: MollieAmount
+        image: MolliePaymentMethodImages
+    }
     type MolliePaymentIntent {
         url: String!
     }
     union MolliePaymentIntentResult = MolliePaymentIntent | MolliePaymentIntentError
     input MolliePaymentIntentInput {
         paymentMethodCode: String!
+        molliePaymentMethodCode: String
+    }
+    input MolliePaymentMethodsInput {
+        paymentMethodCode: String!
     }
     extend type Mutation {
         createMolliePaymentIntent(input: MolliePaymentIntentInput!): MolliePaymentIntentResult!
     }
+    extend type Query {
+        molliePaymentMethods(input: MolliePaymentMethodsInput!): [MolliePaymentMethod!]!
+    }
 `;

+ 35 - 6
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -64,21 +64,50 @@ export interface MolliePluginOptions {
  * mutation CreateMolliePaymentIntent {
  *   createMolliePaymentIntent(input: {
  *     paymentMethodCode: "mollie-payment-method"
+ *     molliePaymentMethodCode: "ideal"
  *   }) {
  *          ... on MolliePaymentIntent {
-                url
-            }
-            ... on MolliePaymentIntentError {
-                errorCode
-                message
-            }
+ *               url
+ *           }
+ *          ... on MolliePaymentIntentError {
+ *               errorCode
+ *               message
+ *          }
  *   }
  * }
  * ```
+ *
  * The response will contain
  * a redirectUrl, which can be used to redirect your customer to the Mollie
  * platform.
  *
+ * 'molliePaymentMethodCode' is an optional parameter that can be passed to skip Mollie's hosted payment method selection screen
+ * You can get available Mollie payment methods with the following query:
+ *
+ * ```GraphQL
+ * {
+ *  molliePaymentMethods(input: { paymentMethodCode: "mollie-payment-method" }) {
+ *    id
+ *    code
+ *    description
+ *    minimumAmount {
+ *      value
+ *      currency
+ *    }
+ *    maximumAmount {
+ *      value
+ *      currency
+ *    }
+ *    image {
+ *      size1x
+ *      size2x
+ *      svg
+ *    }
+ *  }
+ * }
+ * ```
+ * You can pass `MolliePaymentMethod.code` to the `createMolliePaymentIntent` mutation to skip the method selection.
+ *
  * After completing payment on the Mollie platform,
  * the user is redirected to the configured redirect url + orderCode: `https://storefront/order/CH234X5`
  *

+ 14 - 5
packages/payments-plugin/src/mollie/mollie.resolver.ts

@@ -1,10 +1,10 @@
-import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
 import { Allow, Ctx, Permission, RequestContext } from '@vendure/core';
 
 import {
     MolliePaymentIntent,
-    MolliePaymentIntentError,
-    MolliePaymentIntentResult,
+    MolliePaymentIntentError, MolliePaymentIntentInput,
+    MolliePaymentIntentResult, MolliePaymentMethod, MolliePaymentMethodsInput,
 } from './graphql/generated-shop-types';
 import { MollieService } from './mollie.service';
 
@@ -16,9 +16,9 @@ export class MollieResolver {
     @Allow(Permission.Owner)
     async createMolliePaymentIntent(
         @Ctx() ctx: RequestContext,
-        @Args('input') input: { paymentMethodCode: string },
+        @Args('input') input: MolliePaymentIntentInput,
     ): Promise<MolliePaymentIntentResult> {
-        return this.mollieService.createPaymentIntent(ctx, input.paymentMethodCode);
+        return this.mollieService.createPaymentIntent(ctx, input);
     }
 
     @ResolveField()
@@ -30,4 +30,13 @@ export class MollieResolver {
             return 'MolliePaymentIntent';
         }
     }
+
+    @Query()
+    @Allow(Permission.Public)
+    async molliePaymentMethods(
+        @Ctx() ctx: RequestContext,
+        @Args('input') { paymentMethodCode }: MolliePaymentMethodsInput
+    ): Promise<MolliePaymentMethod[]> {
+        return this.mollieService.getEnabledPaymentMethods(ctx, paymentMethodCode);
+    }
 }

+ 53 - 19
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -8,6 +8,7 @@ import {
     Logger,
     Order,
     OrderService,
+    PaymentMethod,
     PaymentMethodService,
     RequestContext,
 } from '@vendure/core';
@@ -17,9 +18,13 @@ import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
 import {
     ErrorCode,
     MolliePaymentIntentError,
+    MolliePaymentIntentInput,
     MolliePaymentIntentResult,
+    MolliePaymentMethod,
 } from './graphql/generated-shop-types';
 import { MolliePluginOptions } from './mollie.plugin';
+import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/payments/parameters';
+import { PaymentMethod as MollieClientMethod } from '@mollie/api-client';
 
 interface SettlePaymentInput {
     channelToken: string;
@@ -29,11 +34,21 @@ interface SettlePaymentInput {
 
 class PaymentIntentError implements MolliePaymentIntentError {
     errorCode = ErrorCode.ORDER_PAYMENT_STATE_ERROR;
-    constructor(public message: string) {}
+
+    constructor(public message: string) {
+    }
+}
+
+class InvalidInput implements MolliePaymentIntentError {
+    errorCode = ErrorCode.INELIGIBLE_PAYMENT_METHOD_ERROR;
+
+    constructor(public message: string) {
+    }
 }
 
 @Injectable()
 export class MollieService {
+
     constructor(
         private paymentMethodService: PaymentMethodService,
         @Inject(PLUGIN_INIT_OPTIONS) private options: MolliePluginOptions,
@@ -48,11 +63,15 @@ export class MollieService {
      */
     async createPaymentIntent(
         ctx: RequestContext,
-        paymentMethodCode: string,
+        { paymentMethodCode, molliePaymentMethodCode }: MolliePaymentIntentInput,
     ): Promise<MolliePaymentIntentResult> {
-        const [order, paymentMethods] = await Promise.all([
+        const allowedMethods = Object.values(MollieClientMethod) as string[];
+        if (molliePaymentMethodCode && !allowedMethods.includes(molliePaymentMethodCode)) {
+            return new InvalidInput(`molliePaymentMethodCode has to be one of "${allowedMethods.join(',')}"`);
+        }
+        const [order, paymentMethod] = await Promise.all([
             this.activeOrderService.getOrderFromContext(ctx),
-            this.paymentMethodService.findAll(ctx),
+            this.getPaymentMethod(ctx, paymentMethodCode),
         ]);
         if (!order) {
             return new PaymentIntentError('No active order found for session');
@@ -67,29 +86,21 @@ export class MollieService {
         if (!order.shippingLines?.length) {
             return new PaymentIntentError('Cannot create payment intent for order without shippingMethod');
         }
-        const paymentMethod = paymentMethods.items.find(pm => pm.code === paymentMethodCode);
         if (!paymentMethod) {
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
         }
-        const apiKeyArg = paymentMethod.handler.args.find(arg => arg.name === 'apiKey');
-        const redirectUrlArg = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl');
-        if (!apiKeyArg || !redirectUrlArg) {
-            Logger.warn(
-                `CreatePaymentIntent failed, because no apiKey or redirect is configured for ${paymentMethod.code}`,
-                loggerCtx,
-            );
-            return new PaymentIntentError(
-                `Paymentmethod ${paymentMethod.code} has no apiKey or redirectUrl configured`,
-            );
+        const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
+        let redirectUrl = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
+        if (!apiKey || !redirectUrl) {
+            Logger.warn(`CreatePaymentIntent failed, because no apiKey or redirect is configured for ${paymentMethod.code}`, loggerCtx);
+            return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey or redirectUrl configured`);
         }
-        const apiKey = apiKeyArg.value;
-        let redirectUrl = redirectUrlArg.value;
         const mollieClient = createMollieClient({ apiKey });
         redirectUrl = redirectUrl.endsWith('/') ? redirectUrl.slice(0, -1) : redirectUrl; // remove appending slash
         const vendureHost = this.options.vendureHost.endsWith('/')
             ? this.options.vendureHost.slice(0, -1)
             : this.options.vendureHost; // remove appending slash
-        const payment = await mollieClient.payments.create({
+        const paymentInput: CreateParameters = {
             amount: {
                 value: `${(order.totalWithTax / 100).toFixed(2)}`,
                 currency: order.currencyCode,
@@ -100,7 +111,11 @@ export class MollieService {
             description: `Order ${order.code}`,
             redirectUrl: `${redirectUrl}/${order.code}`,
             webhookUrl: `${vendureHost}/payments/mollie/${ctx.channel.token}/${paymentMethod.id}`,
-        });
+        };
+        if (molliePaymentMethodCode) {
+            paymentInput.method = molliePaymentMethodCode as MollieClientMethod;
+        }
+        const payment = await mollieClient.payments.create(paymentInput);
         const url = payment.getCheckoutUrl();
         if (!url) {
             throw Error(`Unable to getCheckoutUrl() from Mollie payment`);
@@ -178,6 +193,25 @@ export class MollieService {
         Logger.info(`Payment for order ${molliePayment.metadata.orderCode} settled`, loggerCtx);
     }
 
+    async getEnabledPaymentMethods(ctx: RequestContext, paymentMethodCode: string): Promise<MolliePaymentMethod[]> {
+        const paymentMethod = await this.getPaymentMethod(ctx, paymentMethodCode);
+        const apiKey = paymentMethod?.handler.args.find(arg => arg.name === 'apiKey')?.value;
+        if (!apiKey) {
+            throw Error(`No apiKey configured for payment method ${paymentMethodCode}`);
+        }
+        const client = createMollieClient({ apiKey });
+        const methods = await client.methods.list();
+        return methods.map(m => ({
+            ...m,
+            code: m.id,
+        }));
+    }
+
+    private async getPaymentMethod(ctx: RequestContext, paymentMethodCode: string): Promise<PaymentMethod | undefined> {
+        const paymentMethods = await this.paymentMethodService.findAll(ctx);
+        return paymentMethods.items.find(pm => pm.code === paymentMethodCode);
+    }
+
     private async createContext(channelToken: string): Promise<RequestContext> {
         const channel = await this.channelService.getChannelFromToken(channelToken);
         return new RequestContext({

+ 4 - 0
packages/testing/src/test-server.ts

@@ -114,6 +114,10 @@ export class TestServer {
                 cors: config.apiOptions.cors,
                 logger: new Logger(),
             });
+            const earlyMiddlewares = config.apiOptions.middleware.filter(mid => mid.beforeListen);
+            earlyMiddlewares.forEach(mid => {
+                app.use(mid.route, mid.handler);
+            });
             await app.listen(config.apiOptions.port);
             await app.get(JobQueueService).start();
             DefaultLogger.restoreOriginalLogLevel();