Разработка кастомного плагина Vendure

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка кастомного плагина Vendure
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Разработка кастомного плагина Vendure

Плагины в Vendure — это NestJS-модули с дополнительными декораторами. Механизм расширения полностью предсказуем: плагин может добавлять GraphQL-резолверы, сущности БД, сервисы, обработчики событий, новые поля в существующие типы. Никаких monkey-patching, только DI и TypeORM.

Структура плагина

src/plugins/loyalty/
├── loyalty.plugin.ts          # Точка входа (NestJS Module)
├── loyalty.service.ts         # Бизнес-логика
├── loyalty.resolver.ts        # GraphQL резолверы
├── loyalty.entity.ts          # TypeORM сущность
├── loyalty-ui/                # Admin UI расширение (опционально)
│   ├── loyalty.module.ts
│   └── components/
└── types.ts                   # GraphQL типы

Декоратор @VendurePlugin

// loyalty.plugin.ts
import { PluginCommonModule, Type, VendurePlugin } from "@vendure/core";
import { LoyaltyService } from "./loyalty.service";
import { LoyaltyResolver } from "./loyalty.resolver";
import { LoyaltyAccount } from "./loyalty.entity";
import { loyaltyShopApiExtensions, loyaltyAdminApiExtensions } from "./api-extensions";

@VendurePlugin({
  imports: [PluginCommonModule],
  entities: [LoyaltyAccount],
  shopApiExtensions: {
    schema: loyaltyShopApiExtensions,
    resolvers: [LoyaltyResolver],
  },
  adminApiExtensions: {
    schema: loyaltyAdminApiExtensions,
    resolvers: [LoyaltyAdminResolver],
  },
  providers: [LoyaltyService],
  configuration: (config) => {
    // Можно модифицировать глобальный конфиг
    config.orderOptions.orderItemPriceCalculationStrategy =
      new LoyaltyAwarePriceStrategy();
    return config;
  },
})
export class LoyaltyPlugin {}

TypeORM сущность

// loyalty.entity.ts
import {
  DeepPartial,
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  CreateDateColumn,
  UpdateDateColumn,
} from "typeorm";
import { Customer, VendureEntity } from "@vendure/core";

@Entity()
export class LoyaltyAccount extends VendureEntity {
  constructor(input?: DeepPartial<LoyaltyAccount>) {
    super(input);
  }

  @ManyToOne(() => Customer, { onDelete: "CASCADE" })
  customer: Customer;

  @Column()
  customerId: string;

  @Column({ default: 0 })
  points: number;

  @Column({ type: "jsonb", nullable: true })
  transactions: LoyaltyTransaction[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

interface LoyaltyTransaction {
  type: "earn" | "spend";
  points: number;
  orderId?: string;
  reason: string;
  date: string;
}

После добавления сущности в entities: [LoyaltyAccount] Vendure создаст таблицу через миграцию.

GraphQL расширение схемы

// api-extensions.ts
import gql from "graphql-tag";

export const loyaltyShopApiExtensions = gql`
  type LoyaltyAccount {
    id: ID!
    points: Int!
    transactions: [LoyaltyTransaction!]!
  }

  type LoyaltyTransaction {
    type: String!
    points: Int!
    reason: String!
    date: DateTime!
    orderId: ID
  }

  extend type Query {
    myLoyaltyAccount: LoyaltyAccount
  }

  extend type Mutation {
    redeemLoyaltyPoints(points: Int!): Order!
  }
`;

// Расширение существующего типа Customer в Admin API
export const loyaltyAdminApiExtensions = gql`
  extend type Customer {
    loyaltyAccount: LoyaltyAccount
  }
`;

Сервис с EventBus

// loyalty.service.ts
import { Injectable } from "@nestjs/common";
import { EventBus, OrderPlacedEvent, RequestContext, TransactionalConnection } from "@vendure/core";
import { OnEvent } from "@nestjs/event-emitter";
import { LoyaltyAccount } from "./loyalty.entity";

@Injectable()
export class LoyaltyService implements OnApplicationBootstrap {
  constructor(
    private connection: TransactionalConnection,
    private eventBus: EventBus,
  ) {}

  onApplicationBootstrap() {
    // Подписываемся на событие завершения заказа
    this.eventBus.ofType(OrderPlacedEvent).subscribe(async (event) => {
      await this.awardPointsForOrder(event.ctx, event.order);
    });
  }

  async awardPointsForOrder(ctx: RequestContext, order: Order) {
    const customerId = order.customerId;
    if (!customerId) return; // анонимный заказ

    const pointsToAward = Math.floor(order.totalWithTax / 100); // 1 балл = 100 коп

    await this.connection.withTransaction(ctx, async (em) => {
      let account = await em.findOne(LoyaltyAccount, {
        where: { customerId },
      });

      if (!account) {
        account = new LoyaltyAccount({
          customerId,
          points: 0,
          transactions: [],
        });
      }

      account.points += pointsToAward;
      account.transactions = [
        ...account.transactions,
        {
          type: "earn",
          points: pointsToAward,
          orderId: order.id,
          reason: `Заказ #${order.code}`,
          date: new Date().toISOString(),
        },
      ];

      await em.save(account);
    });
  }

  async getAccountByCustomer(ctx: RequestContext, customerId: string) {
    return this.connection
      .getRepository(ctx, LoyaltyAccount)
      .findOne({ where: { customerId } });
  }

  async redeemPoints(ctx: RequestContext, customerId: string, points: number) {
    const account = await this.getAccountByCustomer(ctx, customerId);
    if (!account || account.points < points) {
      throw new UserInputError("Недостаточно баллов");
    }
    account.points -= points;
    account.transactions.push({
      type: "spend",
      points,
      reason: "Списание при заказе",
      date: new Date().toISOString(),
    });
    return this.connection.getRepository(ctx, LoyaltyAccount).save(account);
  }
}

GraphQL Resolver

// loyalty.resolver.ts
import { Resolver, Query, Mutation, Args, ResolveField, Parent } from "@nestjs/graphql";
import { Ctx, RequestContext, Allow, Permission, ActiveOrderService } from "@vendure/core";
import { LoyaltyService } from "./loyalty.service";

@Resolver()
export class LoyaltyResolver {
  constructor(
    private loyaltyService: LoyaltyService,
    private activeOrderService: ActiveOrderService,
  ) {}

  @Query()
  @Allow(Permission.Owner)
  async myLoyaltyAccount(@Ctx() ctx: RequestContext) {
    if (!ctx.activeUserId) return null;
    return this.loyaltyService.getAccountByCustomer(
      ctx,
      ctx.activeUserId.toString()
    );
  }

  @Mutation()
  @Allow(Permission.Owner)
  async redeemLoyaltyPoints(
    @Ctx() ctx: RequestContext,
    @Args("points") points: number,
  ) {
    const order = await this.activeOrderService.getActiveOrder(ctx, undefined);
    if (!order) throw new Error("No active order");
    await this.loyaltyService.redeemPoints(ctx, ctx.activeUserId!.toString(), points);
    // добавить скидку к заказу...
    return order;
  }
}

Регистрация плагина в конфиге

// vendure-config.ts
import { LoyaltyPlugin } from "./plugins/loyalty/loyalty.plugin";

export const config: VendureConfig = {
  // ...
  plugins: [
    // ...
    LoyaltyPlugin,
  ],
};

Миграция после добавления плагина

npm run build
npx ts-node src/migration.ts generate src/migrations/AddLoyaltyPlugin
npx ts-node src/migration.ts run

Тестирование плагина

// loyalty.service.spec.ts
import { Test } from "@nestjs/testing";
import { createTestEnvironment, testConfig } from "@vendure/testing";
import { LoyaltyPlugin } from "./loyalty.plugin";

describe("LoyaltyPlugin", () => {
  const { server, adminClient, shopClient } = createTestEnvironment({
    ...testConfig,
    plugins: [LoyaltyPlugin],
  });

  beforeAll(async () => {
    await server.init({
      initialData,
      productsCsvPath: path.join(__dirname, "test-products.csv"),
    });
  });

  it("awards points after order placement", async () => {
    await shopClient.asUserWithCredentials("[email protected]", "test");
    await shopClient.query(ADD_ITEM_TO_ORDER, { variantId: "T_1", quantity: 1 });
    // ... complete checkout
    const { myLoyaltyAccount } = await shopClient.query(GET_LOYALTY_ACCOUNT);
    expect(myLoyaltyAccount.points).toBeGreaterThan(0);
  });
});

Vendure предоставляет createTestEnvironment — полноценный тестовый инстанс с in-memory SQLite, без моков.