Разработка кастомного плагина 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, без моков.







