Medusa Plugin | Create OCI and cXML PunchOut Catalogues | PunchCommerce                            ![](//analytics.punchcommerce.de/matomo.php?idsite=1&rec=1)

Medusa Plugin
=============

A Medusa v2 plugin that integrates with [PunchCommerce](https://www.punchcommerce.de/) to enable cXML/PunchOut procurement gateway functionality. Procurement systems redirect buyers to your Medusa storefront, where they can browse items and add them to a shopping basket, before transferring the basket back to the procurement system.

How it works
------------

1. **The buyer clicks on a PunchOut link** in their ERP → PunchCommerce redirects them to the **entry route of your storefront** with the query parameters `sID` and `uID`.
2. **The storefront authenticates the buyer** via the Medusa SDK using the `punchcommerce` auth provider (`sdk.auth.login("customer", "punchcommerce", { sID, uID })`).
3. **Storefront creates a new shopping basket** for the session and stores the `sID` in `cart.metadata.punchcommerce_session_id`. The shopper browses as normal — items are added via the standard Store API.
4. **At checkout**, Storefront calls `GET /store/punchout/basket?cart_id=...` to retrieve the PunchOut basket payload + a `punchoutUrl`, and sends the basket as a `multipart/form-data` form to this URL.

Installation
------------

```shell
npm install @punchcommerce/punchcommerce-medusa-plugin
```

Configuration
-------------

The plugin requires **two** entries in your `medusa-config.ts`: the plugin itself and an auth provider within the auth module.

Add the plugin to `medusa-config.ts`:

```ts
 plugins: [
 // ... other plugins
    {
 resolve: "@punchcommerce/punchcommerce-medusa-plugin",
 options: {
        punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
 },
    },
 ]
```

Register the punchcommerce-auth provider:

```ts
// medusa-config.ts
module.exports = defineConfig({
  modules: [
    {
 resolve: "@medusajs/medusa/auth",
 options: {
 providers: [
 // ... other providers
 {
            resolve: "@punchcommerce/punchcommerce-medusa-plugin/providers/punchcommerce-auth",
 id: "punchcommerce",
 options: {
 punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
              disableSessionValidation: false, // never disable in production
 },
 },
 ],
 },
    },
  ],
})
```

### Options

OptionRequiredDefaultDescription`punchcommerceUrl`No`https://www.punchcommerce.de`Base URL of the PunchCommerce gateway. Override for staging or self-hosted instances. Must be passed to **both** entries (plugin and auth provider).`disableSessionValidation`No`false`Auth provider option. If `true`, the call to `GET /gateway/v3/session/validate` is skipped and any syntactically correct `sID` is accepted. Intended for local development without a live PunchCommerce instance — **never enable in production**.> Note: The gateway version is currently set to `v3` (see `src/modules/punchcommerce-client/service.ts`).

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 `uID`.
2. **In Medusa Admin**: Open the customer’s details page. In the right-hand sidebar, the **PunchCommerce** widget displays the current link.
    - Click **Add** (or the pencil icon) and enter the `uID`.
    - If the same `uID` is already linked to another customer, the API will return an error and the widget will display this.
    - Use the bin icon to unlink the customer. The link will also be removed automatically if the customer is deleted.

PunchCommerce Configuration
---------------------------

In the PunchCommerce dashboard, configure each customer with:

- **Entry address**: Your storefront’s PunchOut landing route, e.g. `https://my-store.com//punchcommerce/authenticate`
- **Customer identification (Customer identification)**: The same `uID` that you entered in the Medusa Admin.

PunchCommerce redirects shoppers to the entry address, appending `?sID={UUID}&uID={identifier}` (plus any [action parameters](#punchout-actions-optional)).

Storefront requirements
-----------------------

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

### 1. Authentication route

Create a route to which PunchCommerce redirects. It must call the Medusa SDK using the `punchcommerce` provider and persist the auth token + `sID`.

> All examples use the Next.js starter template:

```ts
// app/[region]/punchcommerce/authenticate/route.ts (Next.js)
import { sdk } from "@lib/config"
import { setAuthToken } from "@lib/data/cookies"
import { NextRequest, NextResponse } from "next/server"

export async function GET(request: NextRequest) {
  const sID = request.nextUrl.searchParams.get("sID")
  const uID = request.nextUrl.searchParams.get("uID")
  if (!sID || !uID) {
    // an error page can also be rendered here
    return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
  }

  const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
  if (typeof token !== "string") {
    // a custom error page can also be rendered here
    return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
  }

  await setAuthToken(token)
  const res = NextResponse.redirect(new URL("/store", process.env.NEXT_PUBLIC_BASE_URL))

 return res
}
```

What this triggers in the backend (see `src/providers/punchcommerce-auth/service.ts`):

1. The `sID` is validated against `GET /gateway/v3/session/validate` (unless `disableSessionValidation` is set).
2. The provider identity is looked up via `entity_id = uID`. If no customer is linked to this `uID`, the request fails with `"No PunchCommerce Identity found."`.
3. If successful, Medusa returns an auth token that is restricted to the associated customer.

### 2. Session-based shopping basket

After authentication, create a **new** shopping basket for the PunchOut session and append the `sID` to its metadata. All Store API operations relating to this shopping basket inherit this association.

```ts
const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })
await sdk.store.cart.update(cart.id, {
  metadata: { punchcommerce_session_id: sID },
})
```

Existing shopping baskets that the customer has outside of PunchOut remain unaffected. `getPunchOutCartStep` ensures that the shopping basket used for each transfer or action has the `punchcommerce_session_id` set.

### 3. PunchOut page (replaces checkout)

Instead of the normal checkout, render a dedicated `/punchout` page that loads the prepared shopping basket from the backend, displays it to the purchaser for review, and, upon clicking, sends it to PunchCommerce via a form. The buyer never sees the JSON payload — only the shopping basket summary and a ‘Send to procurement system’ button.

**Data Loader** (server action that calls the Store API):

```ts
// lib/data/punchcommerce.ts
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCartId } from "./cookies"

export async function getPunchOutBasket() {
  const cartId = await getCartId()
  if (!cartId) return null

  return sdk.client.fetch(
    `/store/punchout/basket`,
    {
 method: "GET",
 cache: "no-store",
 query: { cart_id: cartId },
 headers: { ...(await getAuthHeaders()) },
    }
  )
}
```

**PunchOut page**:

```tsx
// app/[countryCode]/(main)/punchout/page.tsx
export default async function PunchOutPage() {
  const data = await getPunchOutBasket()
  if (!data) return notFound()

  const { basket, punchoutUrl } = data

  return (

 Complete PunchOut

        {basket.map((item, i) => (

 {item.quantity} × {item.product_name} ({item.product_ordernumber})

 ))}

 {/* The hidden field MUST wrap the array in `{ basket }` — this is the
 format expected by PunchCommerce’s /gateway/v3/return endpoint. */}

        Send to procurement system

  )
}
```

### 4. PunchOut actions (optional)

PunchCommerce can append `actions[]` to the entry URL to instruct the storefront to perform additional steps immediately after authentication. The backend provides `GET /store/punchout/actions` to process these, and the storefront decides what to do with the response.

ActionRequired parametersEffect`restore-basket``items=SKU:QTY,SKU:QTY`Adds the listed items to the current basket. Missing SKUs are returned as warnings.`detail``ordernumber=SKU`Retrieves the product handle for the SKU. Storefront redirects to the product detail page.`search``keyword=…`Storefront redirects to its own search results page.`background-search``keyword=…`The backend creates a basket from the search results and returns it together with a `punchoutUrl` (for inline PunchOut sessions that return search results).A few things to bear in mind before implementation:

- **`restore-basket` is always executed**, if present, regardless of other actions. It modifies the basket and may add `warning` notifications for missing SKUs. It never sets a navigation response.
- **Only the first action that produces results takes precedence.** If `actions[]` contains both `detail` and `search`, the backend processes the first one and skips the rest.
- The name of the input action is `background-search` (hyphen), but the response discriminator is `background_search` (**underscore**) — always branch based on `response.type`, not the raw input string.
- `notifications` (e.g. “SKU X not found”) persist beyond the action call, even if a redirect follows. Store them in a cookie or a Flash session to display them to the shopper after the redirect.

**Data Loader** (add this alongside `getPunchOutBasket` in `lib/data/punchcommerce.ts`):

> **Note:** In the authentication route, the auth token and shopping basket have only just been created, so `getCartId()`/`getAuthHeaders()` may not yet be able to read the newly set cookies. Pass both values explicitly from the route; the default values will still work for other callers (e.g. when loading the loader from the `/punchout` page after the session has been established).

```ts
// lib/data/punchcommerce.ts
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCartId } from "./cookies"

type PunchOutActionNotification = { type: "info" | "warning"; message: string }
type PunchOutActionResponse =
  | { type: "default" }
  | { type: "detail"; product_handle: string }
  | { type: "search"; keyword: string }
  | { type: "background_search"; basket: PunchOutPosition[]; punchoutUrl: string }

export async function processPunchOutActions(
  params: URLSearchParams,
  opts: { cartId?: string; authHeaders?: Record } = {}
): Promise {
  const cartId = opts.cartId ?? (await getCartId())
  if (!cartId) return null
  const headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }

  // Pass all action parameters (actions[], items, ordernumber, keyword) plus the shopping basket.
  const query = new URLSearchParams(params)
  query.set("cart_id", cartId)

  return sdk.client.fetch(`/store/punchout/actions?${query.toString()}`, {
    method: "GET",
    cache: "no-store",
    headers,
  })
}
```

**Extended authentication route** — after `setAuthToken` and creating the shopping basket (steps 1–2), check for actions and branch based on the result:

```ts
// app/[countryCode]/punchcommerce/authenticate/route.ts (extended from Step 1)
import { sdk } from "@lib/config"
import { getCacheTag, setAuthToken, setCartId } from "@lib/data/cookies"
import { processPunchOutActions, PunchOutPosition } from "@lib/data/punchcommerce"
import { NextRequest, NextResponse } from "next/server"

// Renders a page that automatically sends a POST form to PunchCommerce when it loads.
// Used for `background_search`, where the shopper does not manually check the basket.
function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {
  // Escape quotation marks to ensure the JSON is safe within an HTML attribute value.
  const payload = JSON.stringify({ basket }).replace(/"/g, "&quot;")
  return `

      Send to procurement system

  `
}

export async function GET(request: NextRequest, { params }) {
  const { countryCode } = await params
  const url = request.nextUrl
  const sID = url.searchParams.get("sID")
  const uID = url.searchParams.get("uID")
  if (!sID || !uID) {
    return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
  }

  const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
  if (typeof token !== "string") {
    return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
  }

  await setAuthToken(token)

  // Step 2: Create a new session-based shopping basket with punchcommerce_session_id in the metadata.
  const authHeaders = { authorisation: `Bearer ${token}` }
  const { cart } = await sdk.store.cart.create(
    { region_id, metadata: { punchcommerce_session_id: sID } },
    {},
    authHeaders
  )
  await setCartId(cart.id)

  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL!
  const hasActions = url.searchParams.has("actions[]") || url.searchParams.has("actions")

  if (!hasActions) {
    return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
  }

  // Pass cart.id and the token explicitly — cookies are not yet readable in this request.
  const dispatch = await processPunchOutActions(url.searchParams, {
    cartId: cart.id,
    authHeaders,
  })
  const response = dispatch?.response ?? { type: "default" as const }

  switch (response.type) {
    case "detail":
 return NextResponse.redirect(
 new URL(`/${countryCode}/products/${response.product_handle}`, baseUrl)
 )

    case "search":
 // Redirect to the shop page with a search term.
 return NextResponse.redirect(
 new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
      )

 case "background_search":
 // The backend has already created a shopping basket from the results of the keyword search.
      // Return a self-submitting page so that the browser sends the shopping basket directly via POST to
 // PunchCommerce — the shopping basket MUST be wrapped in { basket } (as in step 3).
 return new NextResponse(
 renderAutoSubmitForm(response.punchoutUrl, response.basket),
 { headers: { "content-type": "text/html" } }
 )

 default:
      // `restore-basket` was executed (if requested), but did not set a navigation response — go to the shop.
 return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
  }
}
```

> See also [https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post\_punchcommerce\_authenticate](https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate)

Cart Mapping
------------

The plugin maps each Medusa shopping basket item to a `PunchOutPosition` (`src/modules/punchcommerce-client/transform.ts`):

- `price_net` = the `subtotal` line (net), `price` = `total` (gross), `item_price` = `subtotal / quantity` (net unit price)
- `tax_rate` is taken from the shopping basket item (decimal, e.g. `0.19`)
- `product_name` is truncated to **39 characters** (OCI/cXML restriction)
- `packaging_unit` is fixed at `"Piece"`; variant-specific unit mapping (`PCE`, `KG`, `LTR`, …) is **not yet implemented**.
- The shopping basket is sent as `multipart/form-data` by the storefront to `${punchcommerceUrl}/gateway/v3/return`.

Shopping Basket Lifecycle (Cart Lifecycle)
------------------------------------------

Following a successful transfer, the Medusa shopping basket is **not** automatically marked as completed, archived or deleted — it remains in its current state. Recommended storefront behaviour:

- Start the next PunchOut session by creating a brand-new shopping basket with the new `sID` in its metadata.

(The actual order is created later via PunchCommerce / the ERP — Medusa serves only as a catalogue interface for browsing.)

Parallel Sessions
-----------------

Shopping baskets are scoped per `cart_id`, not per customer. Therefore, a single customer linked to PunchCommerce can have several independent PunchOut sessions active at the same time.

REST API Reference
------------------

### `GET /store/punchout/basket`

Customer-authenticated (Bearer or Session). Creates a PunchOut basket from a session-based Medusa basket.

QueryRequiredDescription`cart_id`YesShopping basket whose metadata contains `punchcommerce_session_id`.**Response:** `{ basket: PunchOutPosition[], punchoutUrl: string }`

### `GET /store/punchout/actions`

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

QueryRequiredDescription`cart_id`YesThe basket on which the operation is to be performed.`actions[]`YesOne or more of the following: `restore-basket`, `detail`, `search`, `background-search`.`items`For `restore-basket`Comma-separated `SKU:QTY` pairs.`ordernumber`For `detail`SKU to be looked up.`keyword`For `search` / `background-search`Free-text search term.**Response:** `{ notifications: PunchOutActionNotification[], response: PunchOutActionResponse }` — see `src/modules/punchcommerce-client/types.ts`.

### `GET | POST | DELETE /admin/customers/:id/punchcommerce-customer`

Admin-authenticated. Supports the customer details widget.

- **GET** → `{ punchcommerce_customer: { uid: string } | null }`
- **POST** Body `{ uid: string }` — Creates or updates the link.
- **DELETE** — Removes the link.

Type Reference
--------------

All types are exported from `punchcommerce/modules/punchcommerce-client/types`.

### `PunchOutPosition`

A single line item in the PunchOut basket. The plugin creates one line item per Medusa basket line item.

```ts
type PunchOutPosition = {
  product_ordernumber: string   // SKU of the variant; primary key in PunchCommerce
  product_name: string // Display name, truncated to 39 characters (OCI/cXML limit)
  quantity: number // Integer quantity for this line item
  item_price: number // Net unit price (= price_net / quantity)
  price: number // Gross line total (including tax) — Medusa’s `line.total`
  price_net: number // Net line total (excluding tax) — Medusa’s `line.subtotal`
  tax_rate: number // Decimal tax rate, e.g. 0.19 for 19%
  type: "product" | "shipping-costs"  // "shipping-costs" is reserved; currently, all lines are products
  product: PunchOutProduct // Embedded product master data (see below)
}
```

### `PunchOutProduct`

Product data embedded in every `PunchOutPosition`. Sent to PunchCommerce so that the procurement system can save/display the product, even if the buyer’s catalogue does not contain it.

```ts
type PunchOutProduct = {
  id: string // Internal product ID (Medusa product_id) — for information purposes only
  ordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumber
  brand_ordernumber: string     // Manufacturer’s order reference; currently identical to `ordernumber`
  title: string // Full, unabbreviated product title
  description: string // Product description in plain text
  image_url?: string | null     // URL to the preview image of the variant or product
  price: number // Net unit price (mirrors PunchOutPosition.item_price)
  currency: string // ISO 4217 code, lower case (e.g. "eur") — taken from the shopping basket
  tax_rate: number // Same decimal value as PunchOutPosition.tax_rate
  packaging_unit: string // Currently fixed at "Piece"; future: per-variant mapping
  shipping_time: number // Currently fixed at 0
  active: "true" | "false" // String (not a Boolean) — PunchCommerce convention

  // Optional fields — not yet populated by this plugin, but accepted by PunchCommerce:
  brand?: string
  customer_ordernumber?: string
  category?: string
  description_long?: string
  purchase_unit?: number
  reference_unit?: number
  unit?: string // OCI unit code, e.g. "PCE", "KG", "LTR"
  unit_name?: string // Human-readable unit name
  weight?: number
  classification_type?: string
  classification?: string
}
```

### `PunchOutBasket`

Top-level shopping basket wrapper. **This is the format expected by the PunchCommerce `/gateway/v3/return` endpoint** — when submitting the form, the item array must be wrapped in `{ basket: [...] }`.

```ts
type PunchOutBasket = {
  basket: PunchOutPosition[]
}
```

### `PunchOutActionItem`

An item passed to the `restore-basket` action. The route parses the `items=SKU:QTY,SKU:QTY` query string into an array of these objects.

```ts
type PunchOutActionItem = {
  sku: string
  quantity: number
}
```

### `PunchOutActionNotification`

Warning / information message returned alongside an action response (e.g. if an SKU was not found in `restore-basket`).

```ts
type PunchOutActionNotification = {
  type: "info" | "warning"
  message: string
}
```

### `PunchOutActionResponse`

Discriminated union returned by `GET /store/punchout/actions`. The storefront branches based on `type` to decide what to do next.

```ts
type PunchOutActionResponse =
  | { type: "default" } // No action returned a result — proceed as normal
  | { type: "detail"; product_handle: string }            // Redirect the shopper to the PDP under this handle
  | { type: "search"; keyword: string } // Redirect to your storefront’s search page
  | { // Inline search PunchOut: send the returned shopping basket
 type: "background_search"
 basket: PunchOutPosition[]
 punchoutUrl: string
    }
```

 [ PunchCommerce® ist ein Produkt der ![Netzdirektion GmbH](https://www.punchcommerce.de/static/netzdirektion-logo.png "PunchCommerce® ist ein Produkt der netzdirektion | Gesellschaft für digitale Wertarbeit mbH") ](https://netzdirektion.de)

 [Give feedback now - your opinion helps us to become even better!](https://easy-feedback.de/umfrage/1883200/5FuM95 "Your opinion helps us to become even better!")
