@vendure-community/punchout-gateway-plugin
v0.1.0
Published
A Vendure plugin for integrating with [PunchCommerce](https://www.punchcommerce.de), a PunchOut gateway that connects your Vendure store with enterprise procurement systems (SAP Ariba, Coupa, etc.) via OCI/cXML protocols.
Readme
PunchOut Gateway Plugin
A Vendure plugin for integrating with PunchCommerce, a PunchOut gateway that connects your Vendure store with enterprise procurement systems (SAP Ariba, Coupa, etc.) via OCI/cXML protocols.
PunchCommerce handles all protocol translation — this plugin only speaks JSON over HTTPS.
How It Works
- Buyer clicks PunchOut link in their ERP → PunchCommerce redirects to your storefront with
sIDanduIDquery params - Storefront authenticates the buyer by calling Vendure's
authenticatemutation with thepunchoutstrategy - Buyer shops normally — all order mutations use
activeOrderInputto scope the cart to the PunchOut session - On checkout, storefront calls
transferPunchOutCart(sID)to send the cart back to PunchCommerce
Installation
npm install @vendure-community/punchout-gateway-pluginConfiguration
import { PunchOutGatewayPlugin } from '@vendure-community/punchout-gateway-plugin';
export const config: VendureConfig = {
plugins: [
PunchOutGatewayPlugin.init({
// All options are optional — defaults work out of the box
}),
],
};Options
| Option | Required | Default | Description |
| --- | --- | --- | --- |
| apiUrl | No | https://www.punchcommerce.de | Base URL of the PunchCommerce gateway. Override for staging or self-hosted instances. |
| shippingCostMode | No | 'nonZero' | Controls shipping line item in the basket: 'all' = always include, 'nonZero' = only when > 0, 'none' = never include. |
| productFieldMapping | No | — | Maps PunchCommerce product fields to static values or ProductVariant custom field names. See below. |
Product Field Mapping
By default, all products are sent as pieces (unit: 'PCE'). If your catalog includes products with different units (weight, volume, etc.), you can map PunchCommerce fields to ProductVariant custom fields or static values.
Each field accepts either a static value or a { customField, default } object that reads from the variant at transfer time:
PunchOutGatewayPlugin.init({
productFieldMapping: {
// Static value for all products
packaging_unit: 'Bag',
// Read from ProductVariant custom field, fall back to 'PCE' if empty
unit: { customField: 'punchOutUnit', default: 'PCE' },
unit_name: { customField: 'punchOutUnitName', default: 'Piece' },
weight: { customField: 'productWeight', default: 0 },
},
})Available fields:
| Field | Default | Description |
| --- | --- | --- |
| unit | 'PCE' | OCI unit code (e.g. 'PCE', 'KG', 'LTR') |
| unit_name | 'Piece' | Human-readable unit name |
| packaging_unit | 'Piece' | Packaging unit description |
| purchase_unit | 1 | Purchase unit quantity |
| reference_unit | 1 | Reference unit quantity |
| weight | 0 | Product weight |
Customer Setup
Customers are linked to PunchCommerce via a custom field on the Customer entity.
- In PunchCommerce: create a customer and set the "Customer identification" (this becomes the
uID) - In Vendure admin: open the customer, set the "PunchOut Customer ID (uID)" custom field to the same value
PunchCommerce Configuration
In the PunchCommerce dashboard, configure your customer:
- Entry address: your storefront's PunchOut landing page URL (e.g.
https://my-store.com/punchout) - Customer identification: a unique identifier matching the Vendure customer's custom field
PunchCommerce will redirect buyers to your Entry address with ?sID={UUID}&uID={identifier} appended.
Storefront Requirements
Since Vendure is headless, your storefront must handle the PunchOut flow. A full working example is available at vendurehq/punchcommerce-storefront-demo.
Here's what needs to be implemented:
1. PunchOut Landing Page
Create a route (e.g. /punchout) that PunchCommerce redirects to. This page must:
- Extract
sIDanduIDfrom the query params - Store the
sIDfor the duration of the session (e.g. insessionStorage) - Call the
authenticatemutation - Redirect to the shop homepage on success
// e.g. https://my-store.com/punchout?sID=abc-123&uID=test-customer
const params = new URLSearchParams(window.location.search);
const sID = params.get('sID');
const uID = params.get('uID');
// Store sID for the session — needed for all order operations
sessionStorage.setItem('punchoutSID', sID);
const { authenticate } = await graphqlClient.mutate({
mutation: gql`
mutation PunchOutLogin($sID: String!, $uID: String!) {
authenticate(input: { punchout: { sID: $sID, uID: $uID } }) {
... on CurrentUser { id }
... on InvalidCredentialsError { message }
}
}
`,
variables: { sID, uID },
});2. Session-Scoped Cart (activeOrderInput)
All order operations (queries and mutations) must include activeOrderInput: { punchout: { sID } } to scope the cart to the PunchOut session. This enables parallel sessions for the same customer.
const sID = sessionStorage.getItem('punchoutSID');
await graphqlClient.mutate({
mutation: gql`
mutation AddItem($variantId: ID!, $qty: Int!, $activeOrderInput: ActiveOrderInput) {
addItemToOrder(
productVariantId: $variantId
quantity: $qty
activeOrderInput: $activeOrderInput
) {
... on Order { id totalWithTax }
... on ErrorResult { message }
}
}
`,
variables: {
variantId: '42',
qty: 1,
activeOrderInput: { punchout: { sID } },
},
});Pass activeOrderInput on all order operations: activeOrder, addItemToOrder, adjustOrderLine, removeOrderLine, setOrderShippingAddress, setOrderShippingMethod, eligibleShippingMethods, etc.
To display the cart, query activeOrder with the same input:
const sID = sessionStorage.getItem('punchoutSID');
const { activeOrder } = await graphqlClient.query({
query: gql`
query PunchOutCart($activeOrderInput: ActiveOrderInput) {
activeOrder(activeOrderInput: $activeOrderInput) {
id
totalWithTax
totalQuantity
lines {
id
quantity
unitPriceWithTax
linePriceWithTax
productVariant { name sku }
}
}
}
`,
variables: {
activeOrderInput: { punchout: { sID } },
},
});3. Transfer Cart (replaces Checkout)
Replace the normal checkout flow with a "Transfer Cart" / "Back to Procurement" button that sends the cart to PunchCommerce:
const { transferPunchOutCart } = await graphqlClient.mutate({
mutation: gql`
mutation TransferCart($sID: String!) {
transferPunchOutCart(sID: $sID) { success message }
}
`,
variables: { sID: sessionStorage.getItem('punchoutSID') },
});
if (transferPunchOutCart.success) {
// Cart transferred — show confirmation to the buyer
}4. iFrame Support (if applicable)
If PunchCommerce is configured for iFrame PunchOut (embedding the shop inside the ERP), your storefront must:
- Set
SameSite=None; Secureon all session cookies - Remove the
X-Frame-Optionsheader during PunchOut sessions - These are typically configured in your web server or storefront framework
GraphQL API Reference
Authentication (built-in mutation)
mutation {
authenticate(input: { punchout: { sID: "...", uID: "..." } }) {
... on CurrentUser { id }
... on InvalidCredentialsError { message }
}
}Transfer Cart
mutation {
transferPunchOutCart(sID: "...") {
success
message
}
}Requires an authenticated PunchOut session.
Cart Mapping
The plugin maps Vendure order lines to PunchCommerce basket positions:
- Prices use gross/net pattern:
price= gross (with tax),price_net= net (without tax) - All monetary values are converted from Vendure's integer cents to decimal (÷ 100)
- Shipping is included as a separate position with
type: 'shipping-costs'(controlled byshippingCostMode) - Product descriptions:
descriptionis plain text (HTML stripped),description_longpreserves HTML - Basket is sent as
multipart/form-datato PunchCommerce's/gateway/v3/returnendpoint
Order Lifecycle
After a successful cart transfer, the order transitions to a custom Transferred state:
AddingItems → Transferred- The order becomes inactive (
active = false), so a new PunchOut session creates a fresh cart - The order and all its line items are preserved in Vendure for record-keeping
- The order is visible in the Vendure admin under Orders with state
Transferred - Re-transferring the same session returns an error since no active order exists
The actual purchase order (PO) comes later through a separate channel — either manually or via cXML order transmission (future scope). The Transferred state represents "cart handed off to procurement system, awaiting PO."
Parallel Sessions
The plugin uses a custom ActiveOrderStrategy to scope orders by PunchOut session ID (sID). At the API level:
- Each PunchOut session gets its own empty cart
- The same customer can have multiple concurrent PunchOut sessions
- Carts are isolated — items added in one session don't appear in another
Storefront considerations
Browser cookies are scoped per-domain, not per-tab. If your storefront stores the sID in a cookie, only one PunchOut session can be active at a time — starting a new session overwrites the cookie and the previous session's cart becomes inaccessible from the UI.
To support truly parallel sessions, store the sID in sessionStorage (which is tab-scoped) and pass it explicitly to server actions. This way each browser tab/iframe maintains its own independent PunchOut session.
When a new PunchOut session starts and replaces the previous sID, make sure to revalidate any cached cart data so the UI reflects the new (empty) cart instead of showing stale items from the previous session.
