Интеграция служб доставки в Medusa.js
Medusa.js — headless commerce-фреймворк на Node.js — строится вокруг концепции плагинов. Доставка реализуется через FulfillmentProvider: классы, которые инкапсулируют работу с конкретным перевозчиком. Стандартный manual провайдер только создаёт записи в базе — без расчёта тарифов и без отправки данных перевозчику.
Архитектура Fulfillment Provider
Каждый провайдер — это сервис, зарегистрированный в контейнере Medusa. Базовый интерфейс:
import { AbstractFulfillmentService } from '@medusajs/medusa';
import { Cart, Fulfillment, LineItem, Order } from '@medusajs/medusa/dist/models';
class MyCourierFulfillmentService extends AbstractFulfillmentService {
static identifier = 'my-courier';
// Доступные методы доставки (отображаются в shipping options)
async getFulfillmentOptions(): Promise<Record<string, unknown>[]> {
return [
{ id: 'my-courier-standard', name: 'Стандарт' },
{ id: 'my-courier-express', name: 'Экспресс' },
];
}
// Валидация option при создании ShippingOption в админке
async validateOption(data: Record<string, unknown>): Promise<boolean> {
return ['my-courier-standard', 'my-courier-express'].includes(
data.id as string
);
}
// Расчёт стоимости для конкретной корзины
async calculatePrice(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
cart: Cart
): Promise<number> {
const weight = cart.items.reduce(
(sum, item) => sum + (item.variant?.weight ?? 100) * item.quantity,
0
);
const toCity = cart.shipping_address?.city ?? '';
const price = await this.getApiRate(optionData.id as string, weight, toCity);
return price; // в наименьших единицах валюты (копейки/центы)
}
// Создание отправления
async createFulfillment(
data: Record<string, unknown>,
items: LineItem[],
order: Order,
fulfillment: Fulfillment
): Promise<Record<string, unknown>> {
const shipment = await this.apiClient.createShipment({
service: data.id,
recipient: order.shipping_address,
items: items.map(i => ({ sku: i.variant?.sku, qty: i.quantity })),
order_ref: order.display_id.toString(),
});
return { tracking_number: shipment.tracking, shipment_id: shipment.id };
}
// Отмена отправления
async cancelFulfillment(fulfillment: Fulfillment): Promise<Record<string, unknown>> {
await this.apiClient.cancelShipment(fulfillment.data.shipment_id as string);
return {};
}
// Нужен ли возврат — провайдер решает
async canCalculate(data: Record<string, unknown>): Promise<boolean> {
return true;
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
cart: Cart
): Promise<Record<string, unknown>> {
return data;
}
}
export default MyCourierFulfillmentService;
Регистрация в проекте Medusa
В medusa-config.js плагин подключается через массив plugins, если оформлен как npm-пакет. Для локальной разработки достаточно зарегистрировать сервис напрямую:
// src/services/my-courier-fulfillment.ts — тот же класс выше
// src/loaders/fulfillment.ts
import { asClass } from 'awilix';
export default async (container) => {
container.register({
myСourierFulfillmentService: asClass(MyCourierFulfillmentService).singleton(),
});
};
В Medusa v2 (с модульной архитектурой) провайдер декларируется через defineProvider:
// В модуле доставки
import { ModuleProvider, Modules } from '@medusajs/utils';
export default ModuleProvider(Modules.FULFILLMENT, {
services: [MyCourierFulfillmentService],
});
HTTP-клиент для API перевозчика
// src/services/my-courier-api.ts
import axios, { AxiosInstance } from 'axios';
export class MyCourierApiClient {
private client: AxiosInstance;
constructor(apiKey: string) {
this.client = axios.create({
baseURL: 'https://api.mycourier.ru/v2',
timeout: 10_000,
headers: { Authorization: `Bearer ${apiKey}` },
});
}
async getRates(serviceCode: string, weight: number, toCity: string): Promise<number> {
const { data } = await this.client.post('/calculate', {
service: serviceCode,
weight: Math.max(0.1, weight / 1000), // граммы -> кг
to_city: toCity,
});
// Возвращаем в копейках для Medusa
return Math.round(data.price * 100);
}
async createShipment(payload: object): Promise<{ tracking: string; id: string }> {
const { data } = await this.client.post('/shipments', payload);
return data;
}
async cancelShipment(shipmentId: string): Promise<void> {
await this.client.delete(`/shipments/${shipmentId}`);
}
}
Webhook: обновление статуса заказа
Medusa поддерживает события через EventBus. Webhook от перевозчика попадает в кастомный роут:
// src/api/routes/webhooks/courier.ts
import { Router } from 'express';
import type { MedusaRequest, MedusaResponse } from '@medusajs/medusa';
const router = Router();
router.post('/courier/webhook', async (req: MedusaRequest, res: MedusaResponse) => {
const { tracking_number, status, event } = req.body;
// Находим fulfillment по трекинг-номеру
const fulfillmentRepo = req.scope.resolve('fulfillmentRepository');
const fulfillment = await fulfillmentRepo.findOne({
where: { data: { tracking_number } },
});
if (!fulfillment) {
return res.sendStatus(404);
}
const orderService = req.scope.resolve('orderService');
if (event === 'delivered') {
await orderService.capturePayment(fulfillment.order_id);
// или просто обновляем мета
}
const eventBus = req.scope.resolve('eventBusService');
await eventBus.emit('fulfillment.tracking_updated', {
fulfillment_id: fulfillment.id,
tracking_number,
status,
});
res.sendStatus(200);
});
export default router;
Роут регистрируется в src/api/index.ts:
import courierWebhookRouter from './routes/webhooks/courier';
export default (rootDirectory: string) => {
const router = Router();
router.use('/store', courierWebhookRouter);
return router;
};
Subscriber: уведомление покупателя
// src/subscribers/tracking-updated.ts
import { OrderService } from '@medusajs/medusa';
class TrackingUpdatedSubscriber {
private orderService: OrderService;
constructor({ orderService, eventBusService }) {
this.orderService = orderService;
eventBusService.subscribe(
'fulfillment.tracking_updated',
this.handleTrackingUpdated.bind(this)
);
}
async handleTrackingUpdated({ fulfillment_id, tracking_number, status }) {
// Отправка email через Notification Provider
// или обновление метаданных заказа
console.log(`Fulfillment ${fulfillment_id}: ${tracking_number} -> ${status}`);
}
}
export default TrackingUpdatedSubscriber;
Кастомный ShippingOption в Admin
После регистрации провайдера в админке создаётся Shipping Option через UI или API:
curl -X POST http://localhost:9000/admin/shipping-options \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "MyCourier Стандарт",
"region_id": "reg_01HXXX",
"provider_id": "my-courier",
"data": { "id": "my-courier-standard" },
"price_type": "calculated",
"requirements": [
{ "type": "max_subtotal", "amount": 100000 }
]
}'
price_type: "calculated" означает, что цена определяется через calculatePrice() провайдера, а не фиксирована.
Сроки реализации
Базовый провайдер с расчётом тарифов и созданием отправлений: 2–3 дня. Добавление webhook-обработчика, subscriber уведомлений и обновления статусов: плюс 1–2 дня. Полноценный npm-пакет с конфигурацией, тестами и поддержкой Medusa v1 и v2: 5–7 дней.







