@punchcommerce/punchcommerce-medusa-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +265 -0
- package/.medusa/server/src/admin/index.mjs +264 -0
- package/.medusa/server/src/api/admin/customers/[id]/punchcommerce-customer/route.js +35 -0
- package/.medusa/server/src/api/middlewares.js +52 -0
- package/.medusa/server/src/api/store/punchout/actions/parse-items.js +32 -0
- package/.medusa/server/src/api/store/punchout/actions/route.js +70 -0
- package/.medusa/server/src/api/store/punchout/basket/route.js +17 -0
- package/.medusa/server/src/modules/punchcommerce-client/index.js +13 -0
- package/.medusa/server/src/modules/punchcommerce-client/service.js +36 -0
- package/.medusa/server/src/modules/punchcommerce-client/transform.js +43 -0
- package/.medusa/server/src/modules/punchcommerce-client/types.js +3 -0
- package/.medusa/server/src/providers/punchcommerce-auth/index.js +10 -0
- package/.medusa/server/src/providers/punchcommerce-auth/service.js +69 -0
- package/.medusa/server/src/subscribers/customer-deleted.js +26 -0
- package/.medusa/server/src/workflows/build-background-search-basket.js +49 -0
- package/.medusa/server/src/workflows/build-punchout-basket.js +40 -0
- package/.medusa/server/src/workflows/delete-punchcommerce-customer.js +12 -0
- package/.medusa/server/src/workflows/lookup-product-handle.js +10 -0
- package/.medusa/server/src/workflows/restore-punchout-basket.js +33 -0
- package/.medusa/server/src/workflows/steps/get-punchout-cart.js +48 -0
- package/.medusa/server/src/workflows/steps/link-punchcommerce-auth-identity.js +86 -0
- package/.medusa/server/src/workflows/steps/lookup-product-handle-by-sku.js +21 -0
- package/.medusa/server/src/workflows/steps/lookup-punchcommerce-uid.js +16 -0
- package/.medusa/server/src/workflows/steps/lookup-variants-by-sku.js +25 -0
- package/.medusa/server/src/workflows/steps/search-products.js +42 -0
- package/.medusa/server/src/workflows/steps/unlink-punchcommerce-auth-identity.js +37 -0
- package/.medusa/server/src/workflows/upsert-punchcommerce-customer.js +10 -0
- package/README.md +513 -0
- package/package.json +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# PunchCommerce Plugin for Medusa
|
|
2
|
+
|
|
3
|
+
A Medusa v2 plugin that integrates with [PunchCommerce](https://www.punchcommerce.de/) to enable cXML/PunchOut procurement gateway functionality.
|
|
4
|
+
Procurement systems redirect buyers to your Medusa storefront where they can browse and add items to a cart,
|
|
5
|
+
then transfer the cart back to the procurement system.
|
|
6
|
+
|
|
7
|
+
## How It Works
|
|
8
|
+
|
|
9
|
+
1. **Buyer clicks a PunchOut link** in their ERP → PunchCommerce redirects to your **storefront's entry route** with `sID` and `uID` query parameters
|
|
10
|
+
2. **Storefront authenticates the buyer** via the Medusa SDK using the `punchcommerce` auth provider (`sdk.auth.login("customer", "punchcommerce", { sID, uID })`)
|
|
11
|
+
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.
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```shell
|
|
17
|
+
npm install @punchcommerce/punchcommerce-medusa-plugin
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
The plugin requires you to add **two** entries to your `medusa-config.ts`: the plugin itself and an auth provider inside the Auth module.
|
|
23
|
+
|
|
24
|
+
Add the plugin to `medusa-config.ts`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
plugins: [
|
|
28
|
+
// ... other plugins
|
|
29
|
+
{
|
|
30
|
+
resolve: "punchcommerce",
|
|
31
|
+
options: {
|
|
32
|
+
punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Register the punchcommerce-auth-Provider:
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// medusa-config.ts
|
|
43
|
+
module.exports = defineConfig({
|
|
44
|
+
modules: [
|
|
45
|
+
{
|
|
46
|
+
resolve: "@medusajs/medusa/auth",
|
|
47
|
+
options: {
|
|
48
|
+
providers: [
|
|
49
|
+
// ... other providers
|
|
50
|
+
{
|
|
51
|
+
resolve: "punchcommerce/providers/punchcommerce-auth",
|
|
52
|
+
id: "punchcommerce",
|
|
53
|
+
options: {
|
|
54
|
+
punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
|
|
55
|
+
disableSessionValidation: false, // never disable in production
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Options
|
|
66
|
+
|
|
67
|
+
| Option | Required | Default | Description |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| `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. |
|
|
70
|
+
| `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**. |
|
|
71
|
+
|
|
72
|
+
> Note: the gateway version is currently pinned to `v3` (see `src/modules/punchcommerce-client/service.ts`).
|
|
73
|
+
|
|
74
|
+
## Customer Setup
|
|
75
|
+
|
|
76
|
+
Customers are linked to PunchCommerce via an identity in Medusa's Auth module.
|
|
77
|
+
|
|
78
|
+
1. **In PunchCommerce**: create a customer and copy the *Customer identification* — this is the `uID`.
|
|
79
|
+
2. **In Medusa admin**: open the customer's detail page. On the right sidebar, the **PunchCommerce** widget shows the current link.
|
|
80
|
+
- Click **Add** (or the pencil icon) and paste the `uID`.
|
|
81
|
+
- If the same `uID` is already linked to another customer, the API returns an error and the widget displays it.
|
|
82
|
+
- Use the trash icon to unlink. The link is also auto-removed when the customer is deleted.
|
|
83
|
+
|
|
84
|
+
## PunchCommerce Configuration
|
|
85
|
+
|
|
86
|
+
In the PunchCommerce dashboard, configure each customer with:
|
|
87
|
+
|
|
88
|
+
* **Entry address**: your storefront's PunchOut landing route, e.g. `https://my-store.com/<region>/punchcommerce/authenticate`
|
|
89
|
+
* **Customer identification**: the same `uID` you entered in the Medusa admin
|
|
90
|
+
|
|
91
|
+
PunchCommerce will redirect buyers to the entry address with `?sID={UUID}&uID={identifier}` appended (plus any [action parameters](#punchout-actions-optional)).
|
|
92
|
+
|
|
93
|
+
## Storefront Requirements
|
|
94
|
+
|
|
95
|
+
The plugin is backend-only. The storefront must orchestrate the PunchOut flow.
|
|
96
|
+
|
|
97
|
+
### 1. Authentication route
|
|
98
|
+
|
|
99
|
+
Create a route that PunchCommerce redirects to. It must call the Medusa SDK with the `punchcommerce` provider and persist the auth token + `sID`.
|
|
100
|
+
|
|
101
|
+
> All examples use the Next.js Starter Template: https://github.com/medusajs/nextjs-starter-medusa
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// app/[region]/punchcommerce/authenticate/route.ts (Next.js)
|
|
105
|
+
import { sdk } from "@lib/config"
|
|
106
|
+
import { setAuthToken } from "@lib/data/cookies"
|
|
107
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
108
|
+
|
|
109
|
+
export async function GET(request: NextRequest) {
|
|
110
|
+
const sID = request.nextUrl.searchParams.get("sID")
|
|
111
|
+
const uID = request.nextUrl.searchParams.get("uID")
|
|
112
|
+
if (!sID || !uID) {
|
|
113
|
+
// you can also render a error-page here
|
|
114
|
+
return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
|
|
118
|
+
if (typeof token !== "string") {
|
|
119
|
+
// you can also render a custom error-page here
|
|
120
|
+
return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await setAuthToken(token)
|
|
124
|
+
const res = NextResponse.redirect(new URL("/store", process.env.NEXT_PUBLIC_BASE_URL))
|
|
125
|
+
|
|
126
|
+
return res
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
What this triggers in the backend (see `src/providers/punchcommerce-auth/service.ts`):
|
|
131
|
+
|
|
132
|
+
1. The `sID` is validated against `GET /gateway/v3/session/validate` (unless `disableSessionValidation` is set).
|
|
133
|
+
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."`.
|
|
134
|
+
3. On success, Medusa returns an auth token scoped to the linked customer.
|
|
135
|
+
|
|
136
|
+
### 2. Session-scoped cart
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })
|
|
142
|
+
await sdk.store.cart.update(cart.id, {
|
|
143
|
+
metadata: { punchcommerce_session_id: sID },
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
### 3. PunchOut Page (replaces checkout)
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
**Data loader** (server action that hits the Store API):
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
// lib/data/punchcommerce.ts
|
|
157
|
+
"use server"
|
|
158
|
+
import { sdk } from "@lib/config"
|
|
159
|
+
import { getAuthHeaders, getCartId } from "./cookies"
|
|
160
|
+
|
|
161
|
+
export async function getPunchOutBasket() {
|
|
162
|
+
const cartId = await getCartId()
|
|
163
|
+
if (!cartId) return null
|
|
164
|
+
|
|
165
|
+
return sdk.client.fetch<{ basket: PunchOutPosition[]; punchoutUrl: string }>(
|
|
166
|
+
`/store/punchout/basket`,
|
|
167
|
+
{
|
|
168
|
+
method: "GET",
|
|
169
|
+
cache: "no-store",
|
|
170
|
+
query: { cart_id: cartId },
|
|
171
|
+
headers: { ...(await getAuthHeaders()) },
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**PunchOut Page**:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
// app/[countryCode]/(main)/punchout/page.tsx
|
|
181
|
+
export default async function PunchOutPage() {
|
|
182
|
+
const data = await getPunchOutBasket()
|
|
183
|
+
if (!data) return notFound()
|
|
184
|
+
|
|
185
|
+
const { basket, punchoutUrl } = data
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div>
|
|
189
|
+
<h1>Complete PunchOut</h1>
|
|
190
|
+
<ul>
|
|
191
|
+
{basket.map((item, i) => (
|
|
192
|
+
<li key={i}>
|
|
193
|
+
{item.quantity} × {item.product_name} ({item.product_ordernumber})
|
|
194
|
+
</li>
|
|
195
|
+
))}
|
|
196
|
+
</ul>
|
|
197
|
+
|
|
198
|
+
<form action={punchoutUrl} method="POST">
|
|
199
|
+
{/* The hidden field MUST wrap the array in `{ basket }` — that is the
|
|
200
|
+
shape PunchCommerce's /gateway/v3/return endpoint expects. */}
|
|
201
|
+
<input type="hidden" name="basket" value={JSON.stringify({ basket })} />
|
|
202
|
+
<button type="submit">Submit to procurement</button>
|
|
203
|
+
</form>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### 4. PunchOut Actions (optional)
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
213
|
+
| Action | Required params | Effect |
|
|
214
|
+
| --- | --- | --- |
|
|
215
|
+
| `restore-basket` | `items=SKU:QTY,SKU:QTY` | Adds the listed items to the current cart. Missing SKUs return as warning notifications. |
|
|
216
|
+
| `detail` | `ordernumber=SKU` | Looks up the product handle for the SKU. Storefront redirects to the product-detail page. |
|
|
217
|
+
| `search` | `keyword=…` | Storefront redirects to its own search results page. |
|
|
218
|
+
| `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). |
|
|
219
|
+
|
|
220
|
+
A few things to keep in mind before implementing:
|
|
221
|
+
|
|
222
|
+
- **`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.
|
|
223
|
+
- **Only the first result-producing action wins.** If `actions[]` contains both `detail` and `search`, the backend processes the first one and skips the rest.
|
|
224
|
+
- 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.
|
|
225
|
+
- `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.
|
|
226
|
+
|
|
227
|
+
**Data loader** (add alongside `getPunchOutBasket` in `lib/data/punchcommerce.ts`):
|
|
228
|
+
|
|
229
|
+
> **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).
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
// lib/data/punchcommerce.ts
|
|
233
|
+
"use server"
|
|
234
|
+
import { sdk } from "@lib/config"
|
|
235
|
+
import { getAuthHeaders, getCartId } from "./cookies"
|
|
236
|
+
|
|
237
|
+
type PunchOutActionNotification = { type: "info" | "warning"; message: string }
|
|
238
|
+
type PunchOutActionResponse =
|
|
239
|
+
| { type: "default" }
|
|
240
|
+
| { type: "detail"; product_handle: string }
|
|
241
|
+
| { type: "search"; keyword: string }
|
|
242
|
+
| { type: "background_search"; basket: PunchOutPosition[]; punchoutUrl: string }
|
|
243
|
+
|
|
244
|
+
export async function processPunchOutActions(
|
|
245
|
+
params: URLSearchParams,
|
|
246
|
+
opts: { cartId?: string; authHeaders?: Record<string, string> } = {}
|
|
247
|
+
): Promise<{ notifications: PunchOutActionNotification[]; response: PunchOutActionResponse } | null> {
|
|
248
|
+
const cartId = opts.cartId ?? (await getCartId())
|
|
249
|
+
if (!cartId) return null
|
|
250
|
+
const headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }
|
|
251
|
+
|
|
252
|
+
// Forward all action params (actions[], items, ordernumber, keyword) plus the cart.
|
|
253
|
+
const query = new URLSearchParams(params)
|
|
254
|
+
query.set("cart_id", cartId)
|
|
255
|
+
|
|
256
|
+
return sdk.client.fetch(`/store/punchout/actions?${query.toString()}`, {
|
|
257
|
+
method: "GET",
|
|
258
|
+
cache: "no-store",
|
|
259
|
+
headers,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Extended authenticate route** — after `setAuthToken` and cart creation (Steps 1–2), check for actions and branch on the result:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// app/[countryCode]/punchcommerce/authenticate/route.ts (extended from Step 1)
|
|
268
|
+
import { sdk } from "@lib/config"
|
|
269
|
+
import { getCacheTag, setAuthToken, setCartId } from "@lib/data/cookies"
|
|
270
|
+
import { processPunchOutActions, PunchOutPosition } from "@lib/data/punchcommerce"
|
|
271
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
272
|
+
|
|
273
|
+
// Renders a page that auto-submits a POST form to PunchCommerce on load.
|
|
274
|
+
// Used for background_search, where the buyer never reviews the basket manually.
|
|
275
|
+
function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {
|
|
276
|
+
// Escape double-quotes so the JSON is safe inside an HTML attribute value.
|
|
277
|
+
const payload = JSON.stringify({ basket }).replace(/"/g, """)
|
|
278
|
+
return `<!doctype html><html><body onload="document.forms[0].submit()">
|
|
279
|
+
<form action="${punchoutUrl}" method="POST">
|
|
280
|
+
<input type="hidden" name="basket" value="${payload}" />
|
|
281
|
+
<noscript><button type="submit">Submit to procurement</button></noscript>
|
|
282
|
+
</form>
|
|
283
|
+
</body></html>`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function GET(request: NextRequest, { params }) {
|
|
287
|
+
const { countryCode } = await params
|
|
288
|
+
const url = request.nextUrl
|
|
289
|
+
const sID = url.searchParams.get("sID")
|
|
290
|
+
const uID = url.searchParams.get("uID")
|
|
291
|
+
if (!sID || !uID) {
|
|
292
|
+
return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
|
|
296
|
+
if (typeof token !== "string") {
|
|
297
|
+
return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await setAuthToken(token)
|
|
301
|
+
|
|
302
|
+
// Step 2: create a new session-scoped cart with punchcommerce_session_id in metadata.
|
|
303
|
+
const authHeaders = { authorization: `Bearer ${token}` }
|
|
304
|
+
const { cart } = await sdk.store.cart.create(
|
|
305
|
+
{ region_id, metadata: { punchcommerce_session_id: sID } },
|
|
306
|
+
{},
|
|
307
|
+
authHeaders
|
|
308
|
+
)
|
|
309
|
+
await setCartId(cart.id)
|
|
310
|
+
|
|
311
|
+
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL!
|
|
312
|
+
const hasActions = url.searchParams.has("actions[]") || url.searchParams.has("actions")
|
|
313
|
+
|
|
314
|
+
if (!hasActions) {
|
|
315
|
+
return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Pass cart.id and the token explicitly — cookies are not yet readable in this request.
|
|
319
|
+
const dispatch = await processPunchOutActions(url.searchParams, {
|
|
320
|
+
cartId: cart.id,
|
|
321
|
+
authHeaders,
|
|
322
|
+
})
|
|
323
|
+
const response = dispatch?.response ?? { type: "default" as const }
|
|
324
|
+
|
|
325
|
+
switch (response.type) {
|
|
326
|
+
case "detail":
|
|
327
|
+
return NextResponse.redirect(
|
|
328
|
+
new URL(`/${countryCode}/products/${response.product_handle}`, baseUrl)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
case "search":
|
|
332
|
+
// Redirect to the store page with a search keyword.
|
|
333
|
+
return NextResponse.redirect(
|
|
334
|
+
new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
case "background_search":
|
|
338
|
+
// The backend already built a basket from the keyword search results.
|
|
339
|
+
// Return an auto-submitting page so the browser POSTs the basket straight to
|
|
340
|
+
// PunchCommerce — the basket MUST be wrapped in { basket } (same as Step 3).
|
|
341
|
+
return new NextResponse(
|
|
342
|
+
renderAutoSubmitForm(response.punchoutUrl, response.basket),
|
|
343
|
+
{ headers: { "content-type": "text/html" } }
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
default:
|
|
347
|
+
// restore-basket ran (if requested) but set no navigation response — go to the store.
|
|
348
|
+
return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
> Also see https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate
|
|
354
|
+
|
|
355
|
+
## Cart Mapping
|
|
356
|
+
|
|
357
|
+
The plugin maps each Medusa cart line item to a `PunchOutPosition` (`src/modules/punchcommerce-client/transform.ts`):
|
|
358
|
+
|
|
359
|
+
* `price_net` = the line `subtotal` (net), `price` = `total` (gross), `item_price` = `subtotal / quantity` (net unit price)
|
|
360
|
+
* `tax_rate` is forwarded from the cart line (decimal, e.g. `0.19`)
|
|
361
|
+
* `product_name` is truncated to **39 characters** (OCI/cXML constraint)
|
|
362
|
+
* `packaging_unit` is hardcoded to `"Piece"`; per-variant unit mapping (`PCE`, `KG`, `LTR`, …) is **not yet implemented**
|
|
363
|
+
* The basket is submitted to `${punchcommerceUrl}/gateway/v3/return` as `multipart/form-data` by the storefront
|
|
364
|
+
|
|
365
|
+
## Cart Lifecycle
|
|
366
|
+
|
|
367
|
+
After a successful transfer, the Medusa cart is **not** automatically marked complete, archived, or deleted — it remains in its current state. Recommended storefront behavior:
|
|
368
|
+
|
|
369
|
+
* Start the next PunchOut session by creating a brand-new cart with the new `sID` in its metadata
|
|
370
|
+
|
|
371
|
+
(The actual purchase order is created later through PunchCommerce / the ERP — Medusa is only the catalog browsing surface.)
|
|
372
|
+
|
|
373
|
+
## Parallel Sessions
|
|
374
|
+
|
|
375
|
+
Carts are scoped per `cart_id`, not per customer, so a single PunchCommerce-linked customer can have multiple independent PunchOut sessions in flight.
|
|
376
|
+
|
|
377
|
+
## REST API Reference
|
|
378
|
+
|
|
379
|
+
### `GET /store/punchout/basket`
|
|
380
|
+
|
|
381
|
+
Customer-authenticated (bearer or session). Builds a PunchOut basket from a session-scoped Medusa cart.
|
|
382
|
+
|
|
383
|
+
| Query | Required | Description |
|
|
384
|
+
| --- | --- | --- |
|
|
385
|
+
| `cart_id` | Yes | Cart whose metadata contains `punchcommerce_session_id`. |
|
|
386
|
+
|
|
387
|
+
**Response:** `{ basket: PunchOutPosition[], punchoutUrl: string }`
|
|
388
|
+
|
|
389
|
+
### `GET /store/punchout/actions`
|
|
390
|
+
|
|
391
|
+
Customer-authenticated. Processes one or more PunchOut entry actions.
|
|
392
|
+
|
|
393
|
+
| Query | Required | Description |
|
|
394
|
+
| --- | --- | --- |
|
|
395
|
+
| `cart_id` | Yes | Cart to operate on. |
|
|
396
|
+
| `actions[]` | Yes | One or more of `restore-basket`, `detail`, `search`, `background-search`. |
|
|
397
|
+
| `items` | For `restore-basket` | Comma-separated `SKU:QTY` pairs. |
|
|
398
|
+
| `ordernumber` | For `detail` | SKU to look up. |
|
|
399
|
+
| `keyword` | For `search` / `background-search` | Free-text search term. |
|
|
400
|
+
|
|
401
|
+
**Response:** `{ notifications: PunchOutActionNotification[], response: PunchOutActionResponse }` — see `src/modules/punchcommerce-client/types.ts`.
|
|
402
|
+
|
|
403
|
+
### `GET | POST | DELETE /admin/customers/:id/punchcommerce-customer`
|
|
404
|
+
|
|
405
|
+
Admin-authenticated. Backs the customer-detail widget.
|
|
406
|
+
|
|
407
|
+
* **GET** → `{ punchcommerce_customer: { uid: string } | null }`
|
|
408
|
+
* **POST** body `{ uid: string }` — upserts the link.
|
|
409
|
+
* **DELETE** — removes the link.
|
|
410
|
+
|
|
411
|
+
## Types Reference
|
|
412
|
+
|
|
413
|
+
All types are exported from `punchcommerce/modules/punchcommerce-client/types`.
|
|
414
|
+
|
|
415
|
+
### `PunchOutPosition`
|
|
416
|
+
|
|
417
|
+
A single line in the PunchOut basket. The plugin builds one position per Medusa cart line item.
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
type PunchOutPosition = {
|
|
421
|
+
product_ordernumber: string // SKU of the variant; primary key in PunchCommerce
|
|
422
|
+
product_name: string // Display name, truncated to 39 chars (OCI/cXML limit)
|
|
423
|
+
quantity: number // Whole-unit count for this line
|
|
424
|
+
item_price: number // Net unit price (= price_net / quantity)
|
|
425
|
+
price: number // Gross line total (with tax) — Medusa's `line.total`
|
|
426
|
+
price_net: number // Net line total (without tax) — Medusa's `line.subtotal`
|
|
427
|
+
tax_rate: number // Decimal tax rate, e.g. 0.19 for 19%
|
|
428
|
+
type: "product" | "shipping-costs" // "shipping-costs" reserved; currently all lines are products
|
|
429
|
+
product: PunchOutProduct // Embedded product master data (see below)
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### `PunchOutProduct`
|
|
434
|
+
|
|
435
|
+
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.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
type PunchOutProduct = {
|
|
439
|
+
id: string // Internal product id (Medusa product_id) — informational
|
|
440
|
+
ordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumber
|
|
441
|
+
brand_ordernumber: string // Manufacturer ordering reference; currently same as `ordernumber`
|
|
442
|
+
title: string // Full untruncated product title
|
|
443
|
+
description: string // Plain-text product description
|
|
444
|
+
image_url?: string | null // Variant or product thumbnail URL
|
|
445
|
+
price: number // Net unit price (mirrors PunchOutPosition.item_price)
|
|
446
|
+
currency: string // ISO 4217 code, lowercase (e.g. "eur") — taken from the cart
|
|
447
|
+
tax_rate: number // Same decimal value as PunchOutPosition.tax_rate
|
|
448
|
+
packaging_unit: string // Hardcoded "Piece" today; future: per-variant mapping
|
|
449
|
+
shipping_time: number // Hardcoded 0 today
|
|
450
|
+
active: "true" | "false" // String (not boolean) — PunchCommerce convention
|
|
451
|
+
|
|
452
|
+
// Optional fields — not populated by this plugin yet, but accepted by PunchCommerce:
|
|
453
|
+
brand?: string
|
|
454
|
+
customer_ordernumber?: string
|
|
455
|
+
category?: string
|
|
456
|
+
description_long?: string
|
|
457
|
+
purchase_unit?: number
|
|
458
|
+
reference_unit?: number
|
|
459
|
+
unit?: string // OCI unit code, e.g. "PCE", "KG", "LTR"
|
|
460
|
+
unit_name?: string // Human-readable unit name
|
|
461
|
+
weight?: number
|
|
462
|
+
classification_type?: string
|
|
463
|
+
classification?: string
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### `PunchOutBasket`
|
|
468
|
+
|
|
469
|
+
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: [...] }`.
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
type PunchOutBasket = {
|
|
473
|
+
basket: PunchOutPosition[]
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### `PunchOutActionItem`
|
|
478
|
+
|
|
479
|
+
Item passed to the `restore-basket` action. The route parses the `items=SKU:QTY,SKU:QTY` query string into an array of these.
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
type PunchOutActionItem = {
|
|
483
|
+
sku: string
|
|
484
|
+
quantity: number
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### `PunchOutActionNotification`
|
|
489
|
+
|
|
490
|
+
Warning / info message returned alongside an action response (e.g. when a SKU in `restore-basket` was not found).
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
type PunchOutActionNotification = {
|
|
494
|
+
type: "info" | "warning"
|
|
495
|
+
message: string
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### `PunchOutActionResponse`
|
|
500
|
+
|
|
501
|
+
Discriminated union returned by `GET /store/punchout/actions`. The storefront branches on `type` to decide what to do next.
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
type PunchOutActionResponse =
|
|
505
|
+
| { type: "default" } // No action produced a result — proceed normally
|
|
506
|
+
| { type: "detail"; product_handle: string } // Redirect the buyer to the PDP at this handle
|
|
507
|
+
| { type: "search"; keyword: string } // Redirect to your storefront's search page
|
|
508
|
+
| { // Inline-search PunchOut: submit the returned basket
|
|
509
|
+
type: "background_search"
|
|
510
|
+
basket: PunchOutPosition[]
|
|
511
|
+
punchoutUrl: string
|
|
512
|
+
}
|
|
513
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@punchcommerce/punchcommerce-medusa-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PunchCommerce Plugin for Medusa",
|
|
5
|
+
"author": "netzdirektion | Gesellschaft für digitale Wertarbeit mbH",
|
|
6
|
+
"homepage": "https://www.punchcommerce.de",
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"files": [
|
|
9
|
+
".medusa/server"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
"./workflows": "./.medusa/server/src/workflows/index.js",
|
|
14
|
+
"./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
|
|
15
|
+
"./modules/*": "./.medusa/server/src/modules/*/index.js",
|
|
16
|
+
"./providers/*": "./.medusa/server/src/providers/*/index.js",
|
|
17
|
+
"./*": "./.medusa/server/src/*.js",
|
|
18
|
+
"./admin": {
|
|
19
|
+
"import": "./.medusa/server/src/admin/index.mjs",
|
|
20
|
+
"require": "./.medusa/server/src/admin/index.js",
|
|
21
|
+
"default": "./.medusa/server/src/admin/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"medusa",
|
|
29
|
+
"plugin",
|
|
30
|
+
"medusa-plugin-other",
|
|
31
|
+
"medusa-plugin",
|
|
32
|
+
"medusa-v2"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "medusa plugin:build",
|
|
36
|
+
"dev": "medusa plugin:develop",
|
|
37
|
+
"prepublishOnly": "medusa plugin:build"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@medusajs/admin-sdk": "2.13.6",
|
|
41
|
+
"@medusajs/cli": "2.13.6",
|
|
42
|
+
"@medusajs/framework": "2.13.6",
|
|
43
|
+
"@medusajs/medusa": "2.13.6",
|
|
44
|
+
"@medusajs/test-utils": "2.13.6",
|
|
45
|
+
"@medusajs/ui": "4.1.6",
|
|
46
|
+
"@medusajs/icons": "2.13.6",
|
|
47
|
+
"@swc/core": "^1.7.28",
|
|
48
|
+
"@types/node": "^20.0.0",
|
|
49
|
+
"@types/react": "^18.3.2",
|
|
50
|
+
"@types/react-dom": "^18.2.25",
|
|
51
|
+
"prop-types": "^15.8.1",
|
|
52
|
+
"react": "^18.2.0",
|
|
53
|
+
"react-dom": "^18.2.0",
|
|
54
|
+
"ts-node": "^10.9.2",
|
|
55
|
+
"typescript": "^5.6.2",
|
|
56
|
+
"vite": "^5.2.11",
|
|
57
|
+
"yalc": "^1.0.0-pre.53"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@medusajs/admin-sdk": "2.13.0",
|
|
61
|
+
"@medusajs/cli": "2.13.0",
|
|
62
|
+
"@medusajs/framework": "2.13.0",
|
|
63
|
+
"@medusajs/test-utils": "2.13.0",
|
|
64
|
+
"@medusajs/medusa": "2.13.0",
|
|
65
|
+
"@medusajs/ui": "4.1.6",
|
|
66
|
+
"@medusajs/icons": "2.13.0"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=20"
|
|
70
|
+
},
|
|
71
|
+
"packageManager": "npm@10.8.2"
|
|
72
|
+
}
|