• Модуль интеграций
  • Сообщество
  • Блог
Документация
Плагины и интеграцииВсе расширения для Medusa от сообществаСтартерыЗапускайте проекты быстрее с готовыми решениями
ЭкспертыПодберите специалиста для разработки и развития вашего проекта на MedusaКейсыПосмотрите примеры Medusa в продакшене и успешные внедрения
Меч Moscow
Комплексная e-commerce платформа на Medusa для московского fashion-бренда

Меч Moscow · Fashion

Gorgo снижает затраты на адаптацию Medusa к локальным рынкам.

Мы разрабатываем плагины интеграции, осуществляем поддержку и развиваем сообщество разработчиков на Medusa в Telegram.

  • Ресурсы Medusa
  • Плагины и интеграции
  • Модуль интеграций
  • Стартеры
  • Эксперты
  • Кейсы
  • Medusa Чат в Telegram
  • Medusa Новости в Telegram
  • Документация Gorgo
  • Связаться с нами
  • TelegramGitHub
Плагины
P

Punchcommerce medusa plugin

PunchOut за считаные минуты, без предварительного опыта. Подключает Medusa v2 к системам закупок через OCI, cXML и IDS-Connect.

npm install @punchcommerce/punchcommerce-medusa-plugin
Категория
Другое
Создано
Punchcommerce
Версия
0.2.2
Последнее обновление
6 дней назад
Ежемесячные загрузки
Загрузка данных
Звезды на Github
0
npmNPM

PunchCommerce Plugin for Medusa

A Medusa v2 plugin that integrates with PunchCommerce to enable cXML/PunchOut procurement gateway functionality. Procurement systems redirect buyers to your Medusa storefront where they can browse and add items to a cart, then transfer the cart back to the procurement system.

How It Works

  1. Buyer clicks a PunchOut link in their ERP → PunchCommerce redirects to your storefront's entry route with and query parameters
  2. Storefront authenticates the buyer via the Medusa SDK using the auth provider ()
  3. Storefront creates a fresh cart for the session and stores in . The buyer shops normally — items are added through the standard Store API.
  4. On checkout, the storefront calls to receive the PunchOut basket payload + a , then submits the basket as a form to that URL.

Installation

Requires Medusa v2.13.6 or newer (any 2.x release).

npm install @punchcommerce/punchcommerce-medusa-plugin

Configuration

The plugin requires you to add two entries to your : the plugin itself and an auth provider inside the Auth module.

Add the plugin to :

1plugins: [
2 // ... other plugins
3 {
4 resolve: "@punchcommerce/punchcommerce-medusa-plugin",
5 options: {
6 punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
7 },
8 },
9 ]

Register the punchcommerce-auth-Provider:

1// medusa-config.ts
2module.exports = defineConfig({
3 modules: [
4 {
5 resolve: "@medusajs/medusa/auth",
6 options: {
7 providers: [
8 // ... other providers
9 {
10 resolve: "@punchcommerce/punchcommerce-medusa-plugin/providers/punchcommerce-auth",
11 id: "punchcommerce",
12 options: {
13 punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
14 disableSessionValidation: false, // never disable in production
15 },
16 },
17 ],
18 },
19 },
20 ],
21})

Options

OptionRequiredDefaultDescription
NoBase URL of the PunchCommerce gateway. Override for staging or self-hosted instances. Pass to both the plugin entry and the auth-provider entry.
NoAuth-provider option. When , skips the call to and accepts any well-formed . Intended for local development without a live PunchCommerce instance — never enable in production.

Note: the gateway version is currently pinned to (see ).

Customer Setup

Customers are linked to PunchCommerce via an identity in Medusa's Auth module.

  1. In PunchCommerce: create a customer and copy the Customer identification — this is the .
  2. In Medusa admin: open the customer's detail page. On the right sidebar, the PunchCommerce widget shows the current link.
    • Click Add (or the pencil icon) and paste the .
    • If the same is already linked to another customer, the API returns an error and the widget displays it.
    • Use the trash icon to unlink. The link is also auto-removed when the customer is deleted.

PunchCommerce Configuration

In the PunchCommerce dashboard, configure each customer with:

  • Entry address: your storefront's PunchOut landing route, e.g.
  • Customer identification: the same you entered in the Medusa admin

PunchCommerce will redirect buyers to the entry address with appended (plus any action parameters).

Storefront Requirements

The plugin is backend-only. The storefront must orchestrate the PunchOut flow.

1. Authentication route

Create a route that PunchCommerce redirects to. It must call the Medusa SDK with the provider and persist the auth token + .

All examples use the Next.js Starter Template: https://github.com/medusajs/nextjs-starter-medusa

1// app/[region]/punchcommerce/authenticate/route.ts (Next.js)
2import { sdk } from "@lib/config"
3import { setAuthToken } from "@lib/data/cookies"
4import { NextRequest, NextResponse } from "next/server"
5
6export async function GET(request: NextRequest) {
7 const sID = request.nextUrl.searchParams.get("sID")
8 const uID = request.nextUrl.searchParams.get("uID")
9 if (!sID || !uID) {
10 // you can also render a error-page here
11 return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
12 }
13
14 const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
15 if (typeof token !== "string") {
16 // you can also render a custom error-page here
17 return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
18 }
19
20 await setAuthToken(token)
21 const res = NextResponse.redirect(new URL("/store", process.env.NEXT_PUBLIC_BASE_URL))
22
23 return res
24}

What this triggers in the backend (see ):

  1. The is validated against (unless is set).
  2. The provider identity is looked up by . If no customer has that linked, the request fails with .
  3. On success, Medusa returns an auth token scoped to the linked customer.

2. Session-scoped cart

After authentication, create a new cart for the PunchOut session and attach the to its metadata. All Store API operations referencing this cart inherit the link.

1const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })
2await sdk.store.cart.update(cart.id, {
3 metadata: { punchcommerce_session_id: sID },
4})

Existing carts the customer owns outside of PunchOut are untouched. enforces that the cart used for any transfer/action has set.

3. PunchOut Page (replaces checkout)

Instead of the normal checkout, render a dedicated page that loads the prepared basket from the backend, shows it to the buyer for review, and submits it to PunchCommerce via a form on click. The buyer never sees the JSON payload — only the cart summary and a "Submit to procurement" button.

Data loader (server action that hits the Store API):

1// lib/data/punchcommerce.ts
2"use server"
3import { sdk } from "@lib/config"
4import { getAuthHeaders, getCartId } from "./cookies"
5
6export async function getPunchOutBasket() {
7 const cartId = await getCartId()
8 if (!cartId) return null
9
10 return sdk.client.fetch<{ basket: PunchOutPosition[]; punchoutUrl: string }>(
11 `/store/punchout/basket`,
12 {
13 method: "GET",
14 cache: "no-store",
15 query: { cart_id: cartId },
16 headers: { ...(await getAuthHeaders()) },
17 }
18 )
19}

PunchOut Page:

1// app/[countryCode]/(main)/punchout/page.tsx
2export default async function PunchOutPage() {
3 const data = await getPunchOutBasket()
4 if (!data) return notFound()
5
6 const { basket, punchoutUrl } = data
7
8 return (
9 <div>
10 <h1>Complete PunchOut</h1>
11 <ul>
12 {basket.map((item, i) => (
13 <li key={i}>
14 {item.quantity} × {item.product_name} ({item.product_ordernumber})
15 </li>
16 ))}
17 </ul>
18
19 <form action={punchoutUrl} method="POST">
20 {/* The hidden field MUST wrap the array in `{ basket }` — that is the
21 shape PunchCommerce's /gateway/v3/return endpoint expects. */}
22 <input type="hidden" name="basket" value={JSON.stringify({ basket })} />
23 <button type="submit">Submit to procurement</button>
24 </form>
25 </div>
26 )
27}

4. PunchOut Actions (optional)

PunchCommerce can append to the entry URL to ask the storefront to perform additional steps right after authentication. The backend exposes to process them and the storefront decides what to do with the response.

ActionRequired paramsEffect
Adds the listed items to the current cart. Missing SKUs return as warning notifications.
Looks up the product handle for the SKU. Storefront redirects to the product-detail page.
Storefront redirects to its own search results page.
Backend builds a basket from search results and returns it together with a (for inline PunchOut sessions that submit search results back).

A few things to keep in mind before implementing:

  • always runs when present, regardless of other actions. It mutates the cart and may add notifications for missing SKUs. It never sets a navigation response.
  • Only the first result-producing action wins. If contains both and , the backend processes the first one and skips the rest.
  • The input action name is (hyphen) but the response discriminant is (underscore) — always branch on , not the raw input string.
  • (e.g. "SKU X not found") survive the action call even when a redirect follows. Store them in a cookie or flash session to surface them to the buyer after the redirect.

Data loader (add alongside in ):

Note: In the authenticate route the auth token and cart were just created, so / may not yet read the freshly-set cookies. Pass both values explicitly from the route; the defaults still work for other callers (e.g. loading the loader from the page after the session is established).

1// lib/data/punchcommerce.ts
2"use server"
3import { sdk } from "@lib/config"
4import { getAuthHeaders, getCartId } from "./cookies"
5
6type PunchOutActionNotification = { type: "info" | "warning"; message: string }
7type PunchOutActionResponse =
8 | { type: "default" }
9 | { type: "detail"; product_handle: string }
10 | { type: "search"; keyword: string }
11 | { type: "background_search"; basket: PunchOutPosition[]; punchoutUrl: string }
12
13export async function processPunchOutActions(
14 params: URLSearchParams,
15 opts: { cartId?: string; authHeaders?: Record<string, string> } = {}
16): Promise<{ notifications: PunchOutActionNotification[]; response: PunchOutActionResponse } | null> {
17 const cartId = opts.cartId ?? (await getCartId())
18 if (!cartId) return null
19 const headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }
20
21 // Forward all action params (actions[], items, ordernumber, keyword) plus the cart.
22 const query = new URLSearchParams(params)
23 query.set("cart_id", cartId)
24
25 return sdk.client.fetch(`/store/punchout/actions?${query.toString()}`, {
26 method: "GET",
27 cache: "no-store",
28 headers,
29 })
30}

Extended authenticate route — after and cart creation (Steps 1–2), check for actions and branch on the result:

1// app/[countryCode]/punchcommerce/authenticate/route.ts (extended from Step 1)
2import { sdk } from "@lib/config"
3import { getCacheTag, setAuthToken, setCartId } from "@lib/data/cookies"
4import { processPunchOutActions, PunchOutPosition } from "@lib/data/punchcommerce"
5import { NextRequest, NextResponse } from "next/server"
6
7// Renders a page that auto-submits a POST form to PunchCommerce on load.
8// Used for background_search, where the buyer never reviews the basket manually.
9function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {
10 // Escape double-quotes so the JSON is safe inside an HTML attribute value.
11 const payload = JSON.stringify({ basket }).replace(/"/g, "&quot;")
12 return `<!doctype html><html><body onload="document.forms[0].submit()">
13 <form action="${punchoutUrl}" method="POST">
14 <input type="hidden" name="basket" value="${payload}" />
15 <noscript><button type="submit">Submit to procurement</button></noscript>
16 </form>
17 </body></html>`
18}
19
20export async function GET(request: NextRequest, { params }) {
21 const { countryCode } = await params
22 const url = request.nextUrl
23 const sID = url.searchParams.get("sID")
24 const uID = url.searchParams.get("uID")
25 if (!sID || !uID) {
26 return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
27 }
28
29 const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
30 if (typeof token !== "string") {
31 return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
32 }
33
34 await setAuthToken(token)
35
36 // Step 2: create a new session-scoped cart with punchcommerce_session_id in metadata.
37 const authHeaders = { authorization: `Bearer ${token}` }
38 const { cart } = await sdk.store.cart.create(
39 { region_id, metadata: { punchcommerce_session_id: sID } },
40 {},
41 authHeaders
42 )
43 await setCartId(cart.id)
44
45 const baseUrl = process.env.NEXT_PUBLIC_BASE_URL!
46 const hasActions = url.searchParams.has("actions[]") || url.searchParams.has("actions")
47
48 if (!hasActions) {
49 return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
50 }
51
52 // Pass cart.id and the token explicitly — cookies are not yet readable in this request.
53 const dispatch = await processPunchOutActions(url.searchParams, {
54 cartId: cart.id,
55 authHeaders,
56 })
57 const response = dispatch?.response ?? { type: "default" as const }
58
59 switch (response.type) {
60 case "detail":
61 return NextResponse.redirect(
62 new URL(`/${countryCode}/products/${response.product_handle}`, baseUrl)
63 )
64
65 case "search":
66 // Redirect to the store page with a search keyword.
67 return NextResponse.redirect(
68 new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
69 )
70
71 case "background_search":
72 // The backend already built a basket from the keyword search results.
73 // Return an auto-submitting page so the browser POSTs the basket straight to
74 // PunchCommerce — the basket MUST be wrapped in { basket } (same as Step 3).
75 return new NextResponse(
76 renderAutoSubmitForm(response.punchoutUrl, response.basket),
77 { headers: { "content-type": "text/html" } }
78 )
79
80 default:
81 // restore-basket ran (if requested) but set no navigation response — go to the store.
82 return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
83 }
84}

Also see https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate

Cart Mapping

The plugin maps each Medusa cart line item to a ():

  • = the line (net), = (gross), = (net unit price)
  • is forwarded from the cart line (decimal, e.g. )
  • is truncated to 39 characters (OCI/cXML constraint)
  • is hardcoded to ; per-variant unit mapping (, , , …) is not yet implemented
  • The basket is submitted to as by the storefront

Cart Lifecycle

After a successful transfer, the Medusa cart is not automatically marked complete, archived, or deleted — it remains in its current state. Recommended storefront behavior:

  • Start the next PunchOut session by creating a brand-new cart with the new in its metadata

(The actual purchase order is created later through PunchCommerce / the ERP — Medusa is only the catalog browsing surface.)

Parallel Sessions

Carts are scoped per , not per customer, so a single PunchCommerce-linked customer can have multiple independent PunchOut sessions in flight.

REST API Reference

Customer-authenticated (bearer or session). Builds a PunchOut basket from a session-scoped Medusa cart.

QueryRequiredDescription
YesCart whose metadata contains .

Response:

Customer-authenticated. Processes one or more PunchOut entry actions.

QueryRequiredDescription
YesCart to operate on.
YesOne or more of , , , .
For Comma-separated pairs.
For SKU to look up.
For / Free-text search term.

Response: — see .

Admin-authenticated. Backs the customer-detail widget.

  • GET →
  • POST body — upserts the link.
  • DELETE — removes the link.

Types Reference

All types are exported from .

A single line in the PunchOut basket. The plugin builds one position per Medusa cart line item.

1type PunchOutPosition = {
2 product_ordernumber: string // SKU of the variant; primary key in PunchCommerce
3 product_name: string // Display name, truncated to 39 chars (OCI/cXML limit)
4 quantity: number // Whole-unit count for this line
5 item_price: number // Net unit price (= price_net / quantity)
6 price: number // Gross line total (with tax) — Medusa's `line.total`
7 price_net: number // Net line total (without tax) — Medusa's `line.subtotal`
8 tax_rate: number // Decimal tax rate, e.g. 0.19 for 19%
9 type: "product" | "shipping-costs" // "shipping-costs" reserved; currently all lines are products
10 product: PunchOutProduct // Embedded product master data (see below)
11}

Product-Data embedded in each PunchOutPosition. Sent to PunchCommerce so the procurement system can store/display the product even if the buyer's catalog doesn't have it.

1type PunchOutProduct = {
2 id: string // Internal product id (Medusa product_id) — informational
3 ordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumber
4 brand_ordernumber: string // Manufacturer ordering reference; currently same as `ordernumber`
5 title: string // Full untruncated product title
6 description: string // Plain-text product description
7 image_url?: string | null // Variant or product thumbnail URL
8 price: number // Net unit price (mirrors PunchOutPosition.item_price)
9 currency: string // ISO 4217 code, lowercase (e.g. "eur") — taken from the cart
10 tax_rate: number // Same decimal value as PunchOutPosition.tax_rate
11 packaging_unit: string // Hardcoded "Piece" today; future: per-variant mapping
12 shipping_time: number // Hardcoded 0 today
13 active: "true" | "false" // String (not boolean) — PunchCommerce convention
14
15 // Optional fields — not populated by this plugin yet, but accepted by PunchCommerce:
16 brand?: string
17 customer_ordernumber?: string
18 category?: string
19 description_long?: string
20 purchase_unit?: number
21 reference_unit?: number
22 unit?: string // OCI unit code, e.g. "PCE", "KG", "LTR"
23 unit_name?: string // Human-readable unit name
24 weight?: number
25 classification_type?: string
26 classification?: string
27}

Top-level basket wrapper. This is the shape the PunchCommerce endpoint expects — when submitting the form, wrap the position array in .

1type PunchOutBasket = {
2 basket: PunchOutPosition[]
3}

Item passed to the action. The route parses the query string into an array of these.

1type PunchOutActionItem = {
2 sku: string
3 quantity: number
4}

Warning / info message returned alongside an action response (e.g. when a SKU in was not found).

1type PunchOutActionNotification = {
2 type: "info" | "warning"
3 message: string
4}

Discriminated union returned by . The storefront branches on to decide what to do next.

1type PunchOutActionResponse =
2 | { type: "default" } // No action produced a result — proceed normally
3 | { type: "detail"; product_handle: string } // Redirect the buyer to the PDP at this handle
4 | { type: "search"; keyword: string } // Redirect to your storefront's search page
5 | { // Inline-search PunchOut: submit the returned basket
6 type: "background_search"
7 basket: PunchOutPosition[]
8 punchoutUrl: string
9 }

Еще в этой категории

Посмотреть все
Другое
Gati logo

Gati

От Devx Commerce

Синхронизируйте Medusa с Gati ERP

Загрузка данных
npm
Другое
Product Reviews logo

Product Reviews

От Lambda Curry

Добавляйте рейтинги, отзывы и модерацию товаров

Загрузка данных
GitHubnpm
Другое
Variant Images logo

Variant Images

От Betanoir

Организуйте и загружайте варианты изображений в Medusa

Загрузка данных
GitHubnpm