Ein Medusa v2 Plugin, das mit PunchCommerce integriert wird, um cXML/PunchOut Procurement Gateway Funktionalität zu ermöglichen. Procurement-Systeme leiten Einkäufer zu deinem Medusa-Storefront weiter, wo sie Artikel durchstöbern und in einen Warenkorb legen können, um diesen anschließend an das Procurement-System zurück zu übertragen.
sID und uID weiter.punchcommerce Auth-Providers (sdk.auth.login("customer", "punchcommerce", { sID, uID })).sID in cart.metadata.punchcommerce_session_id. Der Einkäufer kauft normal ein — Artikel werden über die Standard Store API hinzugefügt.GET /store/punchout/basket?cart_id=... auf, um den PunchOut-Warenkorb-Payload + eine punchoutUrl zu erhalten, und sendet den Warenkorb als multipart/form-data Formular an diese URL.npm install @punchcommerce/punchcommerce-medusa-plugin
Das Plugin erfordert zwei Einträge in deiner medusa-config.ts: das Plugin selbst und einen Auth-Provider innerhalb des Auth-Moduls.
Füge das Plugin zu medusa-config.ts hinzu:
plugins: [
// ... andere Plugins
{
resolve: "@punchcommerce/punchcommerce-medusa-plugin",
options: {
punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
},
},
]
Registriere den punchcommerce-auth-Provider:
// medusa-config.ts
module.exports = defineConfig({
modules: [
{
resolve: "@medusajs/medusa/auth",
options: {
providers: [
// ... andere Provider
{
resolve: "@punchcommerce/punchcommerce-medusa-plugin/providers/punchcommerce-auth",
id: "punchcommerce",
options: {
punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
disableSessionValidation: false, // niemals in Produktion deaktivieren
},
},
],
},
},
],
})
| Option | Erforderlich | Standard | Beschreibung |
|---|---|---|---|
punchcommerceUrl |
Nein | https://www.punchcommerce.de |
Basis-URL des PunchCommerce Gateways. Überschreiben für Staging- oder selbst gehostete Instanzen. Muss an beide Einträge (Plugin und Auth-Provider) übergeben werden. |
disableSessionValidation |
Nein | false |
Auth-Provider Option. Wenn true, wird der Aufruf von GET /gateway/v3/session/validate übersprungen und jede formal korrekte sID akzeptiert. Gedacht für die lokale Entwicklung ohne Live-PunchCommerce-Instanz — niemals in Produktion aktivieren. |
Hinweis: Die Gateway-Version ist derzeit auf
v3festgelegt (siehesrc/modules/punchcommerce-client/service.ts).
Kunden werden über eine Identität im Auth-Modul von Medusa mit PunchCommerce verknüpft.
uID.uID ein.uID bereits mit einem anderen Kunden verknüpft ist, gibt die API einen Fehler zurück und das Widget zeigt diesen an.Konfiguriere im PunchCommerce-Dashboard jeden Kunden mit:
https://my-store.com/<region>/punchcommerce/authenticateuID, die du im Medusa Admin eingegeben hast.PunchCommerce leitet Einkäufer an die Einstiegsadresse weiter, wobei ?sID={UUID}&uID={identifier} angehängt wird (plus etwaige Aktionsparameter).
Das Plugin ist nur für das Backend. Das Storefront muss den PunchOut-Flow orchestrieren.
Erstelle eine Route, auf die PunchCommerce weiterleitet. Sie muss das Medusa SDK mit dem punchcommerce Provider aufrufen und das Auth-Token + sID persistieren.
Alle Beispiele verwenden das 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) {
// hier kann auch eine Fehlerseite gerendert werden
return NextResponse.json({ error: "Fehlende sID oder uID" }, { status: 400 })
}
const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
if (typeof token !== "string") {
// hier kann auch eine benutzerdefinierte Fehlerseite gerendert werden
return NextResponse.json({ error: "Authentifizierung fehlgeschlagen" }, { status: 401 })
}
await setAuthToken(token)
const res = NextResponse.redirect(new URL("/store", process.env.NEXT_PUBLIC_BASE_URL))
return res
}
Was dies im Backend auslöst (siehe src/providers/punchcommerce-auth/service.ts):
sID wird gegen GET /gateway/v3/session/validate validiert (außer disableSessionValidation ist gesetzt).entity_id = uID gesucht. Wenn kein Kunde mit dieser uID verknüpft ist, schlägt die Anfrage mit "No PunchCommerce Identity found." fehl.Erstelle nach der Authentifizierung einen neuen Warenkorb für die PunchOut-Sitzung und hänge die sID an dessen Metadaten an. Alle Store-API-Operationen, die sich auf diesen Warenkorb beziehen, erben die Verknüpfung.
const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })
await sdk.store.cart.update(cart.id, {
metadata: { punchcommerce_session_id: sID },
})
Bestehende Warenkörbe, die der Kunde außerhalb von PunchOut besitzt, bleiben unberührt. getPunchOutCartStep stellt sicher, dass der für jeden Transfer/Aktion verwendete Warenkorb die punchcommerce_session_id gesetzt hat.
Anstatt des normalen Checkouts rendere eine dedizierte /punchout-Seite, die den vorbereiteten Warenkorb vom Backend lädt, dem Einkäufer zur Überprüfung anzeigt und ihn bei Klick über ein Formular an PunchCommerce sendet. Der Einkäufer sieht den JSON-Payload nie — nur die Warenkorb-Zusammenfassung und einen Button "An Beschaffungssystem senden".
Data Loader (Server Action, die die Store API anspricht):
// 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-Seite:
// 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>PunchOut abschließen</h1>
<ul>
{basket.map((item, i) => (
<li key={i}>
{item.quantity} × {item.product_name} ({item.product_ordernumber})
</li>
))}
</ul>
<form action={punchoutUrl} method="POST">
{/* Das versteckte Feld MUSS das Array in `{ basket }` einwickeln — das ist das
Format, das der /gateway/v3/return Endpunkt von PunchCommerce erwartet. */}
<input type="hidden" name="basket" value={JSON.stringify({ basket })} />
<button type="submit">An Beschaffungssystem senden</button>
</form>
</div>
)
}
PunchCommerce kann actions[] an die Einstiegs-URL anhängen, um das Storefront aufzufordern, direkt nach der Authentifizierung zusätzliche Schritte durchzuführen. Das Backend stellt GET /store/punchout/actions zur Verfügung, um diese zu verarbeiten, und das Storefront entscheidet, was mit der Antwort zu tun ist.
| Aktion | Erforderliche Parameter | Effekt |
|---|---|---|
restore-basket |
items=SKU:QTY,SKU:QTY |
Fügt die aufgelisteten Artikel dem aktuellen Warenkorb hinzu. Fehlende SKUs werden als Warnhinweise zurückgegeben. |
detail |
ordernumber=SKU |
Sucht den Product-Handle für die SKU. Storefront leitet zur Produktdetailseite weiter. |
search |
keyword=… |
Storefront leitet zur eigenen Suchergebnisseite weiter. |
background-search |
keyword=… |
Backend erstellt einen Warenkorb aus Suchergebnissen und gibt diesen zusammen mit einer punchoutUrl zurück (für Inline-PunchOut-Sitzungen, die Suchergebnisse zurücksenden). |
Einige Dinge, die vor der Implementierung zu beachten sind:
restore-basket wird immer ausgeführt, wenn vorhanden, unabhängig von anderen Aktionen. Es verändert den Warenkorb und kann warning Benachrichtigungen für fehlende SKUs hinzufügen. Es setzt niemals eine Navigationsantwort.actions[] sowohl detail als auch search enthält, verarbeitet das Backend die erste und überspringt den Rest.background-search (Bindestrich), aber der Diskriminator der Antwort ist background_search (Unterstrich) — verzweige immer nach response.type, nicht nach dem rohen Eingabestring.notifications (z.B. "SKU X nicht gefunden") überdauern den Aktionsaufruf, auch wenn eine Weiterleitung folgt. Speichere sie in einem Cookie oder einer Flash-Sitzung, um sie dem Einkäufer nach der Weiterleitung anzuzeigen.Data Loader (zusammen mit getPunchOutBasket in lib/data/punchcommerce.ts hinzufügen):
Hinweis: In der Authentifizierungs-Route wurden das Auth-Token und der Warenkorb gerade erst erstellt, daher können
getCartId()/getAuthHeaders()die frisch gesetzten Cookies möglicherweise noch nicht lesen. Übergib beide Werte explizit von der Route aus; die Standardwerte funktionieren weiterhin für andere Aufrufer (z.B. beim Laden des Loaders von der/punchout-Seite nach Aufbau der Sitzung).
// 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()) }
// Alle Aktionsparameter (actions[], items, ordernumber, keyword) plus den Warenkorb weiterleiten.
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,
})
}
Erweiterte Authentifizierungs-Route — nach setAuthToken und Warenkorberstellung (Schritte 1–2), prüfe auf Aktionen und verzweige nach dem Ergebnis:
// app/[countryCode]/punchcommerce/authenticate/route.ts (erweitert von Schritt 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"
// Rendert eine Seite, die beim Laden automatisch ein POST-Formular an PunchCommerce sendet.
// Wird für background_search verwendet, wo der Einkäufer den Warenkorb nicht manuell überprüft.
function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {
// Entkomme Anführungszeichen, damit das JSON sicher innerhalb eines HTML-Attributwerts ist.
const payload = JSON.stringify({ basket }).replace(/"/g, """)
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">An Beschaffungssystem senden</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: "Fehlende sID oder uID" }, { status: 400 })
}
const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
if (typeof token !== "string") {
return NextResponse.json({ error: "Authentifizierung fehlgeschlagen" }, { status: 401 })
}
await setAuthToken(token)
// Schritt 2: Erstelle einen neuen sitzungsbezogenen Warenkorb mit punchcommerce_session_id in den Metadaten.
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))
}
// Übergebe cart.id und das Token explizit — Cookies sind in dieser Anfrage noch nicht lesbar.
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":
// Weiterleitung zur Shop-Seite mit einem Suchbegriff.
return NextResponse.redirect(
new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
)
case "background_search":
// Das Backend hat bereits einen Warenkorb aus den Ergebnissen der Keyword-Suche erstellt.
// Gib eine sich selbst sendende Seite zurück, damit der Browser den Warenkorb direkt per POST an
// PunchCommerce sendet — der Warenkorb MUSS in { basket } eingewickelt sein (wie in Schritt 3).
return new NextResponse(
renderAutoSubmitForm(response.punchoutUrl, response.basket),
{ headers: { "content-type": "text/html" } }
)
default:
// restore-basket wurde ausgeführt (falls angefordert), setzte aber keine Navigationsantwort — gehe zum Shop.
return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
}
}
Siehe auch https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate
Das Plugin mappt jede Medusa-Warenkorbposition auf eine PunchOutPosition (src/modules/punchcommerce-client/transform.ts):
price_net = die Zeile subtotal (netto), price = total (brutto), item_price = subtotal / quantity (Netto-Stückpreis)tax_rate wird aus der Warenkorbposition übernommen (dezimal, z.B. 0.19)product_name wird auf 39 Zeichen gekürzt (OCI/cXML Beschränkung)packaging_unit ist fest auf "Piece" eingestellt; ein Varianten-bezogenes Unit-Mapping (PCE, KG, LTR, …) ist noch nicht implementiert.multipart/form-data durch das Storefront an ${punchcommerceUrl}/gateway/v3/return gesendet.Nach einer erfolgreichen Übertragung wird der Medusa-Warenkorb nicht automatisch als abgeschlossen markiert, archiviert oder gelöscht — er bleibt in seinem aktuellen Zustand. Empfohlenes Storefront-Verhalten:
sID in seinen Metadaten erstellst.(Die tatsächliche Bestellung wird später über PunchCommerce / das ERP erstellt — Medusa dient nur als Katalog-Oberfläche zum Browsen.)
Warenkörbe sind pro cart_id abgegrenzt, nicht pro Kunde. Daher kann ein einzelner, mit PunchCommerce verknüpfter Kunde mehrere unabhängige PunchOut-Sitzungen gleichzeitig aktiv haben.
GET /store/punchout/basketKunden-authentifiziert (Bearer oder Session). Erstellt einen PunchOut-Warenkorb aus einem sitzungsbezogenen Medusa-Warenkorb.
| Query | Erforderlich | Beschreibung |
|---|---|---|
cart_id |
Ja | Warenkorb, dessen Metadaten punchcommerce_session_id enthalten. |
Antwort: { basket: PunchOutPosition[], punchoutUrl: string }
GET /store/punchout/actionsKunden-authentifiziert. Verarbeitet eine oder mehrere PunchOut-Einstiegsaktionen.
| Query | Erforderlich | Beschreibung |
|---|---|---|
cart_id |
Ja | Warenkorb, auf dem operiert werden soll. |
actions[] |
Ja | Eine oder mehrere von restore-basket, detail, search, background-search. |
items |
Für restore-basket |
Kommagetrennte SKU:QTY Paare. |
ordernumber |
Für detail |
SKU, die nachgeschlagen werden soll. |
keyword |
Für search / background-search |
Freitext-Suchbegriff. |
Antwort: { notifications: PunchOutActionNotification[], response: PunchOutActionResponse } — siehe src/modules/punchcommerce-client/types.ts.
GET | POST | DELETE /admin/customers/:id/punchcommerce-customerAdmin-authentifiziert. Unterstützt das Kunden-Detail-Widget.
{ punchcommerce_customer: { uid: string } | null }{ uid: string } — Erstellt oder aktualisiert die Verknüpfung.Alle Typen werden von punchcommerce/modules/punchcommerce-client/types exportiert.
PunchOutPositionEine einzelne Position im PunchOut-Warenkorb. Das Plugin erstellt eine Position pro Medusa-Warenkorbposition.
type PunchOutPosition = {
product_ordernumber: string // SKU der Variante; Primärschlüssel in PunchCommerce
product_name: string // Anzeigename, gekürzt auf 39 Zeichen (OCI/cXML Limit)
quantity: number // Ganzzahlige Menge für diese Position
item_price: number // Netto-Stückpreis (= price_net / quantity)
price: number // Brutto-Positionssumme (mit Steuer) — Medusas `line.total`
price_net: number // Netto-Positionssumme (ohne Steuer) — Medusas `line.subtotal`
tax_rate: number // Dezimaler Steuersatz, z.B. 0.19 für 19%
type: "product" | "shipping-costs" // "shipping-costs" reserviert; derzeit sind alle Zeilen Produkte
product: PunchOutProduct // Eingebettete Produkt-Stammdaten (siehe unten)
}
PunchOutProductProdukt-Daten, die in jeder PunchOutPosition eingebettet sind. Werden an PunchCommerce gesendet, damit das Beschaffungssystem das Produkt speichern/anzeigen kann, auch wenn der Katalog des Einkäufers es nicht enthält.
type PunchOutProduct = {
id: string // Interne Produkt-ID (Medusa product_id) — informativ
ordernumber: string // SKU — dupliziert PunchOutPosition.product_ordernumber
brand_ordernumber: string // Hersteller-Bestellreferenz; derzeit identisch mit `ordernumber`
title: string // Voller, ungekürzter Produkttitel
description: string // Produktbeschreibung als Klartext
image_url?: string | null // URL zum Vorschaubild der Variante oder des Produkts
price: number // Netto-Stückpreis (spiegelt PunchOutPosition.item_price wider)
currency: string // ISO 4217 Code, kleingeschrieben (z.B. "eur") — wird vom Warenkorb übernommen
tax_rate: number // Derselbe dezimale Wert wie PunchOutPosition.tax_rate
packaging_unit: string // Heute fest auf "Piece"; Zukunft: pro-Variante Mapping
shipping_time: number // Heute fest auf 0
active: "true" | "false" // String (kein Boolean) — PunchCommerce Konvention
// Optionale Felder — werden von diesem Plugin noch nicht befüllt, aber von PunchCommerce akzeptiert:
brand?: string
customer_ordernumber?: string
category?: string
description_long?: string
purchase_unit?: number
reference_unit?: number
unit?: string // OCI Unit Code, z.B. "PCE", "KG", "LTR"
unit_name?: string // Lesbarer Einheitenname
weight?: number
classification_type?: string
classification?: string
}
PunchOutBasketWarenkorb-Wrapper auf oberster Ebene. Dies ist das Format, das der PunchCommerce /gateway/v3/return Endpunkt erwartet — beim Senden des Formulars muss das Positions-Array in { basket: [...] } eingewickelt werden.
type PunchOutBasket = {
basket: PunchOutPosition[]
}
PunchOutActionItemArtikel, der an die restore-basket Aktion übergeben wird. Die Route parst den items=SKU:QTY,SKU:QTY Query-String in ein Array dieser Objekte.
type PunchOutActionItem = {
sku: string
quantity: number
}
PunchOutActionNotificationWarnung / Info-Meldung, die zusammen mit einer Aktionsantwort zurückgegeben wird (z.B. wenn eine SKU in restore-basket nicht gefunden wurde).
type PunchOutActionNotification = {
type: "info" | "warning"
message: string
}
PunchOutActionResponseDiskriminierte Union, die von GET /store/punchout/actions zurückgegeben wird. Das Storefront verzweigt nach type, um zu entscheiden, was als Nächstes zu tun ist.
type PunchOutActionResponse =
| { type: "default" } // Keine Aktion hat ein Ergebnis geliefert — normal fortfahren
| { type: "detail"; product_handle: string } // Leite den Einkäufer zur PDP unter diesem Handle weiter
| { type: "search"; keyword: string } // Leite zur Suchseite deines Storefronts weiter
| { // Inline-Search PunchOut: sende den zurückgegebenen Warenkorb
type: "background_search"
basket: PunchOutPosition[]
punchoutUrl: string
}