npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@punchcommerce/punchcommerce-medusa-plugin

v0.2.2

Published

PunchOut in minutes — no prior experience required. Connects Medusa v2 to procurement systems via OCI, cXML and IDS-Connect.

Readme

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 sID and uID query parameters
  2. Storefront authenticates the buyer via the Medusa SDK using the punchcommerce auth provider (sdk.auth.login("customer", "punchcommerce", { sID, uID }))
  3. Storefront creates a fresh cart for the session and stores sID in cart.metadata.punchcommerce_session_id. The buyer shops normally — items are added through the standard Store API.
  4. On checkout, the storefront calls GET /store/punchout/basket?cart_id=... to receive the PunchOut basket payload + a punchoutUrl, then submits the basket as a multipart/form-data 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 medusa-config.ts: the plugin itself and an auth provider inside the Auth module.

Add the plugin to medusa-config.ts:

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

Register the punchcommerce-auth-Provider:

// 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

| Option | Required | Default | Description | | --- | --- | --- | --- | | punchcommerceUrl | No | https://www.punchcommerce.de | Base URL of the PunchCommerce gateway. Override for staging or self-hosted instances. Pass to both the plugin entry and the auth-provider entry. | | disableSessionValidation | No | false | Auth-provider option. When true, skips the call to GET /gateway/v3/session/validate and accepts any well-formed sID. Intended for local development without a live PunchCommerce instance — never enable in production. |

Note: the gateway version is currently pinned 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 detail page. On the right sidebar, the PunchCommerce widget shows the current link.
    • Click Add (or the pencil icon) and paste the uID.
    • If the same uID 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. https://my-store.com/<region>/punchcommerce/authenticate
  • Customer identification: the same uID you entered in the Medusa admin

PunchCommerce will redirect buyers to the entry address with ?sID={UUID}&uID={identifier} 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 punchcommerce provider and persist the auth token + sID.

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

// 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) {
    // you can also render a error-page 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") {
    // you can also render a custom error-page 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 by entity_id = uID. If no customer has that uID linked, the request fails with "No PunchCommerce Identity found.".
  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 sID to its metadata. All Store API operations referencing this cart inherit the link.

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 carts the customer owns outside of PunchOut are untouched. getPunchOutCartStep enforces that the cart used for any transfer/action has punchcommerce_session_id set.

3. PunchOut Page (replaces checkout)

Instead of the normal checkout, render a dedicated /punchout 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):

// 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<{ basket: PunchOutPosition[]; punchoutUrl: string }>(
    `/store/punchout/basket`,
    {
      method: "GET",
      cache: "no-store",
      query: { cart_id: cartId },
      headers: { ...(await getAuthHeaders()) },
    }
  )
}

PunchOut Page:

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

  const { basket, punchoutUrl } = data

  return (
    <div>
      <h1>Complete PunchOut</h1>
      <ul>
        {basket.map((item, i) => (
          <li key={i}>
            {item.quantity} × {item.product_name} ({item.product_ordernumber})
          </li>
        ))}
      </ul>

      <form action={punchoutUrl} method="POST">
        {/* The hidden field MUST wrap the array in `{ basket }` — that is the
            shape PunchCommerce's /gateway/v3/return endpoint expects. */}
        <input type="hidden" name="basket" value={JSON.stringify({ basket })} />
        <button type="submit">Submit to procurement</button>
      </form>
    </div>
  )
}

4. PunchOut Actions (optional)

PunchCommerce can append actions[] to the entry URL to ask the storefront to perform additional steps right after authentication. The backend exposes GET /store/punchout/actions to process them and the storefront decides what to do with the response.

| Action | Required params | Effect | | --- | --- | --- | | restore-basket | items=SKU:QTY,SKU:QTY | Adds the listed items to the current cart. Missing SKUs return as warning notifications. | | detail | ordernumber=SKU | Looks up 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=… | Backend builds a basket from search results and returns it together with a punchoutUrl (for inline PunchOut sessions that submit search results back). |

A few things to keep in mind before implementing:

  • restore-basket always runs when present, regardless of other actions. It mutates the cart and may add warning notifications for missing SKUs. It never sets a navigation response.
  • Only the first result-producing action wins. If actions[] contains both detail and search, the backend processes the first one and skips the rest.
  • The input action name is background-search (hyphen) but the response discriminant is background_search (underscore) — always branch on response.type, not the raw input string.
  • notifications (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 getPunchOutBasket in lib/data/punchcommerce.ts):

Note: In the authenticate route the auth token and cart were just created, so getCartId()/getAuthHeaders() 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 /punchout page after the session is established).

// 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<string, string> } = {}
): Promise<{ notifications: PunchOutActionNotification[]; response: PunchOutActionResponse } | null> {
  const cartId = opts.cartId ?? (await getCartId())
  if (!cartId) return null
  const headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }

  // Forward all action params (actions[], items, ordernumber, keyword) plus the cart.
  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 authenticate route — after setAuthToken and cart creation (Steps 1–2), check for actions and branch on the result:

// 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 auto-submits a POST form to PunchCommerce on load.
// Used for background_search, where the buyer never reviews the basket manually.
function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {
  // Escape double-quotes so the JSON is safe inside an HTML attribute value.
  const payload = JSON.stringify({ basket }).replace(/"/g, "&quot;")
  return `<!doctype html><html><body onload="document.forms[0].submit()">
    <form action="${punchoutUrl}" method="POST">
      <input type="hidden" name="basket" value="${payload}" />
      <noscript><button type="submit">Submit to procurement</button></noscript>
    </form>
  </body></html>`
}

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-scoped cart with punchcommerce_session_id in metadata.
  const authHeaders = { authorization: `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 store page with a search keyword.
      return NextResponse.redirect(
        new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
      )

    case "background_search":
      // The backend already built a basket from the keyword search results.
      // Return an auto-submitting page so the browser POSTs the basket straight to
      // PunchCommerce — the basket MUST be wrapped in { basket } (same as Step 3).
      return new NextResponse(
        renderAutoSubmitForm(response.punchoutUrl, response.basket),
        { headers: { "content-type": "text/html" } }
      )

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

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 PunchOutPosition (src/modules/punchcommerce-client/transform.ts):

  • price_net = the line subtotal (net), price = total (gross), item_price = subtotal / quantity (net unit price)
  • tax_rate is forwarded from the cart line (decimal, e.g. 0.19)
  • product_name is truncated to 39 characters (OCI/cXML constraint)
  • packaging_unit is hardcoded to "Piece"; per-variant unit mapping (PCE, KG, LTR, …) is not yet implemented
  • The basket is submitted to ${punchcommerceUrl}/gateway/v3/return as multipart/form-data 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 sID 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 cart_id, not per customer, so a single PunchCommerce-linked customer can have multiple independent PunchOut sessions in flight.

REST API Reference

GET /store/punchout/basket

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

| Query | Required | Description | | --- | --- | --- | | cart_id | Yes | Cart whose metadata contains punchcommerce_session_id. |

Response: { basket: PunchOutPosition[], punchoutUrl: string }

GET /store/punchout/actions

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

| Query | Required | Description | | --- | --- | --- | | cart_id | Yes | Cart to operate on. | | actions[] | Yes | One or more of restore-basket, detail, search, background-search. | | items | For restore-basket | Comma-separated SKU:QTY pairs. | | ordernumber | For detail | SKU to look 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. Backs the customer-detail widget.

  • GET{ punchcommerce_customer: { uid: string } | null }
  • POST body { uid: string } — upserts the link.
  • DELETE — removes the link.

Types Reference

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

PunchOutPosition

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

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

PunchOutProduct

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.

type PunchOutProduct = {
  id: string                    // Internal product id (Medusa product_id) — informational
  ordernumber: string           // SKU — duplicates PunchOutPosition.product_ordernumber
  brand_ordernumber: string     // Manufacturer ordering reference; currently same as `ordernumber`
  title: string                 // Full untruncated product title
  description: string           // Plain-text product description
  image_url?: string | null     // Variant or product thumbnail URL
  price: number                 // Net unit price (mirrors PunchOutPosition.item_price)
  currency: string              // ISO 4217 code, lowercase (e.g. "eur") — taken from the cart
  tax_rate: number              // Same decimal value as PunchOutPosition.tax_rate
  packaging_unit: string        // Hardcoded "Piece" today; future: per-variant mapping
  shipping_time: number         // Hardcoded 0 today
  active: "true" | "false"      // String (not boolean) — PunchCommerce convention

  // Optional fields — not populated by this plugin yet, 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 basket wrapper. This is the shape the PunchCommerce /gateway/v3/return endpoint expects — when submitting the form, wrap the position array in { basket: [...] }.

type PunchOutBasket = {
  basket: PunchOutPosition[]
}

PunchOutActionItem

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

type PunchOutActionItem = {
  sku: string
  quantity: number
}

PunchOutActionNotification

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

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

PunchOutActionResponse

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

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