Преглед изворни кода

feat(dev-server): Fill out and document example multivendor plugin

Michael Bromley пре 3 година
родитељ
комит
9ba67a0d08

+ 12 - 0
packages/dev-server/test-plugins/multivendor-plugin/api/api-extensions.ts

@@ -0,0 +1,12 @@
+import gql from 'graphql-tag';
+
+export const adminApiExtensions = gql`
+    input RegisterSellerInput {
+        shopName: String!
+        administrator: CreateAdministratorInput!
+    }
+
+    extend type Mutation {
+        registerNewSeller(input: RegisterSellerInput!): Channel
+    }
+`;

+ 20 - 0
packages/dev-server/test-plugins/multivendor-plugin/api/mv.resolver.ts

@@ -0,0 +1,20 @@
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+import { CreateAdministratorInput } from '@vendure/common/lib/generated-types';
+import { Allow, Ctx, Permission, RequestContext, Transaction } from '@vendure/core';
+
+import { MultivendorService } from '../service/mv.service';
+
+@Resolver()
+export class MultivendorResolver {
+    constructor(private multivendorService: MultivendorService) {}
+
+    @Mutation()
+    @Transaction()
+    @Allow(Permission.SuperAdmin)
+    registerNewSeller(
+        @Ctx() ctx: RequestContext,
+        @Args() args: { input: { shopName: string; administrator: CreateAdministratorInput } },
+    ) {
+        return this.multivendorService.registerNewSeller(ctx, args.input);
+    }
+}

+ 0 - 76
packages/dev-server/test-plugins/multivendor-plugin/config/mv-fulfillment-process.ts

@@ -1,76 +0,0 @@
-import {
-    CustomFulfillmentProcess,
-    Fulfillment,
-    FulfillmentState,
-    Order,
-    OrderService,
-    RequestContext,
-} from '@vendure/core';
-
-let orderService: OrderService;
-
-export const multivendorFulfillmentProcess: CustomFulfillmentProcess<FulfillmentState> = {
-    init(injector) {
-        orderService = injector.get(OrderService);
-    },
-    async onTransitionEnd(fromState, toState, data) {
-        if (toState === 'Shipped' || toState === 'Delivered') {
-            await checkAggregateOrderFulfillments({
-                ...data,
-                toState,
-                aggregateOrderHandler: async (aggregateOrder, allFulfillmentStates) => {
-                    if (allFulfillmentStates.every(state => state === 'Shipped')) {
-                        await orderService.transitionToState(data.ctx, aggregateOrder.id, 'Shipped');
-                    } else if (allFulfillmentStates.every(state => state === 'Delivered')) {
-                        await orderService.transitionToState(data.ctx, aggregateOrder.id, 'Delivered');
-                    } else if (allFulfillmentStates.some(state => state === 'Delivered')) {
-                        await orderService.transitionToState(
-                            data.ctx,
-                            aggregateOrder.id,
-                            'PartiallyDelivered',
-                        );
-                    } else if (allFulfillmentStates.some(state => state === 'Shipped')) {
-                        await orderService.transitionToState(data.ctx, aggregateOrder.id, 'PartiallyShipped');
-                    }
-                },
-            });
-        }
-    },
-};
-
-async function checkAggregateOrderFulfillments(input: {
-    ctx: RequestContext;
-    orders: Order[];
-    fulfillment: Fulfillment;
-    toState: FulfillmentState;
-    aggregateOrderHandler: (
-        aggregateOrder: Order,
-        allFulfillmentStates: Array<FulfillmentState | 'none'>,
-    ) => Promise<void>;
-}) {
-    const { ctx, orders, fulfillment, toState, aggregateOrderHandler } = input;
-    for (const order of orders) {
-        const aggregateOrder = await orderService.getAggregateOrder(ctx, order);
-        if (aggregateOrder) {
-            const sellerOrders = await orderService.getSellerOrders(ctx, aggregateOrder);
-            const allFulfillmentStates = (
-                await Promise.all(
-                    sellerOrders.map(
-                        async sellerOrder => await orderService.getOrderFulfillments(ctx, sellerOrder),
-                    ),
-                )
-            )
-                .map(fulfillments => {
-                    if (fulfillments.length === 0) {
-                        // This order has no fulfillments yet, so we need to add a placeholder to indicate this
-                        return 'none';
-                    } else {
-                        return fulfillments;
-                    }
-                })
-                .flat()
-                .map(f => (f === 'none' ? f : f.id === fulfillment.id ? toState : f.state));
-            await aggregateOrderHandler(aggregateOrder, allFulfillmentStates);
-        }
-    }
-}

+ 5 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-order-process.ts

@@ -32,6 +32,9 @@ export const multivendorOrderProcess: CustomOrderProcess<any> = {
             }
         }
 
+        // Aggregate orders are allowed to transition to these states without validating
+        // fulfillments, since aggregate orders do not have fulfillments, but will get
+        // transitioned based on the status of the sellerOrders' fulfillments.
         if (order.type !== OrderType.Aggregate) {
             if (toState === 'PartiallyShipped') {
                 const orderWithFulfillments = await findOrderWithFulfillments(ctx, order.id);
@@ -64,6 +67,8 @@ export const multivendorOrderProcess: CustomOrderProcess<any> = {
         if (order.type === OrderType.Seller) {
             const aggregateOrder = await orderService.getAggregateOrder(ctx, order);
             if (aggregateOrder) {
+                // This part is responsible for automatically updating the state of the aggregate Order
+                // based on the fulfillment state of all the the associated seller Orders.
                 const otherSellerOrders = (await orderService.getSellerOrders(ctx, aggregateOrder)).filter(
                     so => !idsAreEqual(so.id, order.id),
                 );

+ 21 - 16
packages/dev-server/test-plugins/multivendor-plugin/config/mv-order-seller-strategy.ts

@@ -19,7 +19,8 @@ import {
     TransactionalConnection,
 } from '@vendure/core';
 
-import { CONNECTED_PAYMENT_METHOD_CODE } from '../constants';
+import { CONNECTED_PAYMENT_METHOD_CODE, MULTIVENDOR_PLUGIN_OPTIONS } from '../constants';
+import { MultivendorPluginOptions } from '../types';
 
 declare module '@vendure/core/dist/entity/custom-entity-fields' {
     interface CustomSellerFields {
@@ -34,6 +35,7 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
     private paymentMethodService: PaymentMethodService;
     private connection: TransactionalConnection;
     private orderService: OrderService;
+    private options: MultivendorPluginOptions;
 
     init(injector: Injector) {
         this.entityHydrator = injector.get(EntityHydrator);
@@ -42,6 +44,7 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
         this.paymentMethodService = injector.get(PaymentMethodService);
         this.connection = injector.get(TransactionalConnection);
         this.orderService = injector.get(OrderService);
+        this.options = injector.get(MULTIVENDOR_PLUGIN_OPTIONS);
     }
 
     async setOrderLineSellerChannel(ctx: RequestContext, orderLine: OrderLine) {
@@ -67,7 +70,6 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
                     partialOrder = {
                         channelId: sellerChannelId,
                         shippingLines: [],
-                        surcharges: [],
                         lines: [],
                         state: 'ArrangingPayment',
                     };
@@ -76,6 +78,7 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
                 partialOrder.lines.push(line);
             }
         }
+
         for (const partialOrder of partialOrders.values()) {
             const shippingLineIds = new Set(partialOrder.lines.map(l => l.shippingLineId));
             partialOrder.shippingLines = order.shippingLines.filter(shippingLine =>
@@ -86,20 +89,6 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
         return [...partialOrders.values()];
     }
 
-    async createSurcharges(ctx: RequestContext, sellerOrder: Order) {
-        // Add the platform fee as a surcharge
-        const surcharge = await this.connection.getRepository(ctx, Surcharge).save(
-            new Surcharge({
-                taxLines: [],
-                sku: '',
-                listPrice: sellerOrder.totalWithTax,
-                listPriceIncludesTax: ctx.channel.pricesIncludeTax,
-                order: sellerOrder,
-            }),
-        );
-        return [surcharge];
-    }
-
     async afterSellerOrdersCreated(ctx: RequestContext, aggregateOrder: Order, sellerOrders: Order[]) {
         const paymentMethod = await this.connection.rawConnection.getRepository(PaymentMethod).findOne({
             where: {
@@ -117,6 +106,8 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
                     `Could not determine Seller Channel for Order ${sellerOrder.code}`,
                 );
             }
+            sellerOrder.surcharges = [await this.createPlatformFeeSurcharge(ctx, sellerOrder)];
+            await this.orderService.applyPriceAdjustments(ctx, sellerOrder);
             await this.entityHydrator.hydrate(ctx, sellerChannel, { relations: ['seller'] });
             const result = await this.orderService.addPaymentToOrder(ctx, sellerOrder.id, {
                 method: paymentMethod.code,
@@ -130,4 +121,18 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
             }
         }
     }
+
+    private async createPlatformFeeSurcharge(ctx: RequestContext, sellerOrder: Order) {
+        const platformFee = Math.round(sellerOrder.totalWithTax * -(this.options.platformFeePercent / 100));
+        return this.connection.getRepository(ctx, Surcharge).save(
+            new Surcharge({
+                taxLines: [],
+                sku: this.options.platformFeeSKU,
+                description: 'Platform fee',
+                listPrice: platformFee,
+                listPriceIncludesTax: true,
+                order: sellerOrder,
+            }),
+        );
+    }
 }

+ 31 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-shipping-eligibility-checker.ts

@@ -0,0 +1,31 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
+import { EntityHydrator, idsAreEqual, ShippingEligibilityChecker } from '@vendure/core';
+
+let entityHydrator: EntityHydrator;
+
+/**
+ * @description
+ * Shipping method is eligible if at least one OrderLine is associated with the Seller's Channel.
+ */
+export const multivendorShippingEligibilityChecker = new ShippingEligibilityChecker({
+    code: 'multivendor-shipping-eligibility-checker',
+    description: [{ languageCode: LanguageCode.en, value: 'Multivendor Shipping Eligibility Checker' }],
+    args: {},
+    init(injector) {
+        entityHydrator = injector.get(EntityHydrator);
+    },
+    check: async (ctx, order, args, method) => {
+        await entityHydrator.hydrate(ctx, method, { relations: ['channels'] });
+        const sellerChannel = method.channels.find(c => c.code !== DEFAULT_CHANNEL_CODE);
+        if (!sellerChannel) {
+            return false;
+        }
+        for (const line of order.lines) {
+            if (idsAreEqual(line.sellerChannelId, sellerChannel.id)) {
+                return true;
+            }
+        }
+        return false;
+    },
+});

+ 1 - 0
packages/dev-server/test-plugins/multivendor-plugin/constants.ts

@@ -1 +1,2 @@
 export const CONNECTED_PAYMENT_METHOD_CODE = 'connected-payment-method';
+export const MULTIVENDOR_PLUGIN_OPTIONS = Symbol('MULTIVENDOR_PLUGIN_OPTIONS');

+ 121 - 1
packages/dev-server/test-plugins/multivendor-plugin/multivendor.plugin.ts

@@ -12,12 +12,116 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 
+import { adminApiExtensions } from './api/api-extensions';
+import { MultivendorResolver } from './api/mv.resolver';
 import { multivendorOrderProcess } from './config/mv-order-process';
 import { MultivendorSellerStrategy } from './config/mv-order-seller-strategy';
 import { multivendorPaymentMethodHandler } from './config/mv-payment-handler';
+import { multivendorShippingEligibilityChecker } from './config/mv-shipping-eligibility-checker';
 import { MultivendorShippingLineAssignmentStrategy } from './config/mv-shipping-line-assignment-strategy';
-import { CONNECTED_PAYMENT_METHOD_CODE } from './constants';
+import { CONNECTED_PAYMENT_METHOD_CODE, MULTIVENDOR_PLUGIN_OPTIONS } from './constants';
+import { MultivendorService } from './service/mv.service';
+import { MultivendorPluginOptions } from './types';
 
+/**
+ * @description
+ * This is an example of how to implement a multivendor marketplace app using the new features introduced in
+ * Vendure v2.0.
+ *
+ * ## Setup
+ *
+ * Add this plugin to your VendureConfig:
+ * ```ts
+ *  plugins: [
+ *    MultivendorPlugin.init({
+ *        platformFeePercent: 10,
+ *        platformFeeSKU: 'FEE',
+ *    }),
+ *    // ...
+ *  ]
+ * ```
+ *
+ * ## Create a Seller
+ *
+ * Now you can create new sellers with the following mutation:
+ *
+ * ```graphql
+ * mutation RegisterSeller {
+ *   registerNewSeller(input: {
+ *     shopName: "Bob's Parts",
+ *     administrator: {
+ *       firstName: "Bob"
+ *       lastName: "Dobalina"
+ *       emailAddress: "bob@bobs-parts.com"
+ *       password: "test",
+ *       roleIds: []
+ *     }
+ *   }) {
+ *     id
+ *     code
+ *     token
+ *   }
+ * }
+ * ```
+ *
+ * This mutation will:
+ *
+ * - Create a new Seller representing the shop "Bob's Parts"
+ * - Create a new Channel and associate it with the new Seller
+ * - Create a Role & Administrator for Bob to access his shop admin account
+ * - Create a ShippingMethod for Bob's shop
+ *
+ * Bob can then go and sign in to the Admin UI using the provided emailAddress & password credentials, and start
+ * creating some products.
+ *
+ * Repeat this process for more Sellers.
+ *
+ * ## Storefront
+ *
+ * To create a multivendor Order, use the default Channel in the storefront and add variants to an Order from
+ * various Sellers.
+ *
+ * ### Shipping
+ *
+ * When it comes to setting the shipping method, the `eligibleShippingMethods` query should just return the
+ * shipping methods for the shops from which the OrderLines come. So assuming the Order contains items from 3 different
+ * Sellers, there should be at least 3 eligible ShippingMethods (plus any global ones from the default Channel).
+ *
+ * You should now select the IDs of all the Seller-specific ShippingMethods:
+ *
+ * ```graphql
+ * mutation {
+ *   setOrderShippingMethod(shippingMethodId: ["3", "4"]) {
+ *     ... on Order {
+ *       id
+ *     }
+ *   }
+ * }
+ * ```
+ *
+ * ### Payment
+ *
+ * This plugin automatically creates a "connected payment method" in the default Channel, which is a simple simulation
+ * of something like Stripe Connect.
+ *
+ * ```graphql
+ * mutation {
+ *   addPaymentToOrder(input: { method: "connected-payment-method", metadata: {} }) {
+ *     ... on Order { id }
+ *     ... on ErrorResult {
+ *       errorCode
+ *       message
+ *     }
+ *     ... on PaymentFailedError {
+ *       paymentErrorMessage
+ *     }
+ *   }
+ * }
+ * ```
+ *
+ * After that, you should be able to see that the Order has been split into an "aggregate" order in the default Channel,
+ * and then one or more "seller" orders in each Channel from which the customer bought items.
+ */
 @VendurePlugin({
     imports: [PluginCommonModule],
     configuration: config => {
@@ -37,12 +141,23 @@ import { CONNECTED_PAYMENT_METHOD_CODE } from './constants';
         });
         config.orderOptions.process = [customDefaultOrderProcess, multivendorOrderProcess];
         config.orderOptions.orderSellerStrategy = new MultivendorSellerStrategy();
+        config.shippingOptions.shippingEligibilityCheckers.push(multivendorShippingEligibilityChecker);
         config.shippingOptions.shippingLineAssignmentStrategy =
             new MultivendorShippingLineAssignmentStrategy();
         return config;
     },
+    adminApiExtensions: {
+        schema: adminApiExtensions,
+        resolvers: [MultivendorResolver],
+    },
+    providers: [
+        MultivendorService,
+        { provide: MULTIVENDOR_PLUGIN_OPTIONS, useFactory: () => MultivendorPlugin.options },
+    ],
 })
 export class MultivendorPlugin implements OnApplicationBootstrap {
+    static options: MultivendorPluginOptions;
+
     constructor(
         private connection: TransactionalConnection,
         private channelService: ChannelService,
@@ -50,6 +165,11 @@ export class MultivendorPlugin implements OnApplicationBootstrap {
         private paymentMethodService: PaymentMethodService,
     ) {}
 
+    static init(options: MultivendorPluginOptions) {
+        MultivendorPlugin.options = options;
+        return MultivendorPlugin;
+    }
+
     async onApplicationBootstrap() {
         await this.ensureConnectedPaymentMethodExists();
     }

+ 1 - 0
packages/dev-server/test-plugins/multivendor-plugin/payment/mv-connect-sdk.ts

@@ -1,4 +1,5 @@
 /**
+ * @description
  * A fake payment API based loosely on the Stripe Connect multiparty payments flow
  * described here: https://stripe.com/docs/connect/charges-transfers
  */

+ 157 - 0
packages/dev-server/test-plugins/multivendor-plugin/service/mv.service.ts

@@ -0,0 +1,157 @@
+import { Injectable } from '@nestjs/common';
+import { CreateAdministratorInput, Permission } from '@vendure/common/lib/generated-types';
+import { normalizeString } from '@vendure/common/lib/normalize-string';
+import {
+    AdministratorService,
+    Channel,
+    ChannelService,
+    ConfigService,
+    defaultShippingCalculator,
+    InternalServerError,
+    isGraphQlErrorResult,
+    manualFulfillmentHandler,
+    RequestContext,
+    RoleService,
+    SellerService,
+    ShippingMethod,
+    ShippingMethodService,
+    TaxSetting,
+} from '@vendure/core';
+
+import { multivendorShippingEligibilityChecker } from '../config/mv-shipping-eligibility-checker';
+
+@Injectable()
+export class MultivendorService {
+    constructor(
+        private administratorService: AdministratorService,
+        private sellerService: SellerService,
+        private roleService: RoleService,
+        private channelService: ChannelService,
+        private shippingMethodService: ShippingMethodService,
+        private configService: ConfigService,
+    ) {}
+
+    async registerNewSeller(
+        ctx: RequestContext,
+        input: { shopName: string; administrator: CreateAdministratorInput },
+    ) {
+        const channel = await this.createSellerChannelRoleAdmin(ctx, input);
+        await this.createSellerShippingMethod(ctx, input.shopName, channel);
+        return channel;
+    }
+
+    private async createSellerShippingMethod(ctx: RequestContext, shopName: string, sellerChannel: Channel) {
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
+        const { shippingEligibilityCheckers, shippingCalculators, fulfillmentHandlers } =
+            this.configService.shippingOptions;
+        const shopCode = normalizeString(shopName, '-');
+        const checker = shippingEligibilityCheckers.find(
+            c => c.code === multivendorShippingEligibilityChecker.code,
+        );
+        const calculator = shippingCalculators.find(c => c.code === defaultShippingCalculator.code);
+        const fulfillmentHandler = fulfillmentHandlers.find(h => h.code === manualFulfillmentHandler.code);
+        if (!checker) {
+            throw new InternalServerError(
+                'Could not find a suitable ShippingEligibilityChecker for the seller',
+            );
+        }
+        if (!calculator) {
+            throw new InternalServerError('Could not find a suitable ShippingCalculator for the seller');
+        }
+        if (!fulfillmentHandler) {
+            throw new InternalServerError('Could not find a suitable FulfillmentHandler for the seller');
+        }
+        const shippingMethod = await this.shippingMethodService.create(ctx, {
+            code: `${shopCode}-shipping`,
+            checker: {
+                code: checker.code,
+                arguments: [],
+            },
+            calculator: {
+                code: calculator.code,
+                arguments: [
+                    { name: 'rate', value: '500' },
+                    { name: 'includesTax', value: TaxSetting.auto },
+                    { name: 'taxRate', value: '20' },
+                ],
+            },
+            fulfillmentHandler: fulfillmentHandler.code,
+            translations: [
+                {
+                    languageCode: defaultChannel.defaultLanguageCode,
+                    name: `Standard Shipping for ${shopName}`,
+                },
+            ],
+        });
+
+        await this.channelService.assignToChannels(ctx, ShippingMethod, shippingMethod.id, [
+            sellerChannel.id,
+        ]);
+    }
+
+    private async createSellerChannelRoleAdmin(
+        ctx: RequestContext,
+        input: { shopName: string; administrator: CreateAdministratorInput },
+    ) {
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
+        const shopCode = normalizeString(input.shopName, '-');
+        const seller = await this.sellerService.create(ctx, {
+            name: input.shopName,
+            customFields: {
+                connectedAccountId: Math.random().toString(30).substring(3),
+            },
+        });
+        const channel = await this.channelService.create(ctx, {
+            code: shopCode,
+            sellerId: seller.id,
+            token: `${shopCode}-token`,
+            currencyCode: defaultChannel.currencyCode,
+            defaultLanguageCode: defaultChannel.defaultLanguageCode,
+            pricesIncludeTax: defaultChannel.pricesIncludeTax,
+            defaultShippingZoneId: defaultChannel.defaultShippingZone.id,
+            defaultTaxZoneId: defaultChannel.defaultTaxZone.id,
+        });
+        if (isGraphQlErrorResult(channel)) {
+            throw new InternalServerError(channel.message);
+        }
+        const superAdminRole = await this.roleService.getSuperAdminRole(ctx);
+        const customerRole = await this.roleService.getCustomerRole(ctx);
+        await this.roleService.assignRoleToChannel(ctx, superAdminRole.id, channel.id);
+        const role = await this.roleService.create(ctx, {
+            code: `${shopCode}-admin`,
+            channelIds: [channel.id],
+            description: `Administrator of ${input.shopName}`,
+            permissions: [
+                Permission.CreateCatalog,
+                Permission.UpdateSeller,
+                Permission.ReadCatalog,
+                Permission.DeleteCatalog,
+                Permission.CreateOrder,
+                Permission.ReadOrder,
+                Permission.UpdateOrder,
+                Permission.DeleteOrder,
+                Permission.ReadCustomer,
+                Permission.ReadPaymentMethod,
+                Permission.ReadShippingMethod,
+                Permission.ReadPromotion,
+                Permission.ReadCountry,
+                Permission.ReadZone,
+                Permission.CreateCustomer,
+                Permission.UpdateCustomer,
+                Permission.DeleteCustomer,
+                Permission.CreateTag,
+                Permission.ReadTag,
+                Permission.UpdateTag,
+                Permission.DeleteTag,
+            ],
+        });
+        const administrator = await this.administratorService.create(ctx, {
+            firstName: input.administrator.firstName,
+            lastName: input.administrator.lastName,
+            emailAddress: input.administrator.emailAddress,
+            password: input.administrator.password,
+            roleIds: [role.id],
+        });
+        return channel;
+    }
+}

+ 4 - 0
packages/dev-server/test-plugins/multivendor-plugin/types.ts

@@ -0,0 +1,4 @@
+export interface MultivendorPluginOptions {
+    platformFeePercent: number;
+    platformFeeSKU: string;
+}