Інтеграція служб доставки у Medusa.js
Medusa.js — це фреймворк без веб-серверу для комерції на 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';
// Доступні методи доставки (відображаються в параметрах доставки)
async getFulfillmentOptions(): Promise<Record<string, unknown>[]> {
return [
{ id: 'my-courier-standard', name: 'Стандарт' },
{ id: 'my-courier-express', name: 'Експрес' },
];
}
// Валідація параметра при створенні 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;
// Знаходимо виконання за номером відстеження
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 }) {
// Відправлення електронної пошти через Notification Provider
// або оновлення метаданих замовлення
console.log(`Виконання ${fulfillment_id}: ${tracking_number} -> ${status}`);
}
}
export default TrackingUpdatedSubscriber;
Користувацька ShippingOption в Admin
Після реєстрації провайдера в адміні створюється ShippingOption через інтерфейс або 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 днів.







