@legenki/print2medusa 0.1.0 → 0.3.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 +138 -1
- package/.medusa/server/src/admin/index.mjs +139 -2
- package/.medusa/server/src/api/admin/printful/webhook/route.js +78 -0
- package/.medusa/server/src/api/hooks/printful/[token]/route.js +80 -0
- package/.medusa/server/src/api/middlewares.js +60 -0
- package/.medusa/server/src/jobs/retry-webhook-events.js +65 -0
- package/.medusa/server/src/modules/printful/migrations/Migration20260731000000.js +62 -0
- package/.medusa/server/src/modules/printful/models/printful-webhook-event.js +18 -0
- package/.medusa/server/src/modules/printful/service.js +123 -3
- package/.medusa/server/src/providers/printful-fulfillment/service.js +272 -15
- package/.medusa/server/src/subscribers/payment-captured.js +1 -1
- package/.medusa/server/src/utils/mappers.js +78 -21
- package/.medusa/server/src/utils/order-state.js +58 -0
- package/.medusa/server/src/utils/printful-client.js +37 -1
- package/.medusa/server/src/utils/shipping-rates.js +156 -0
- package/.medusa/server/src/utils/webhook-events.js +117 -0
- package/.medusa/server/src/utils/webhook-path.js +44 -0
- package/.medusa/server/src/workflows/apply-order-status.js +244 -0
- package/.medusa/server/src/workflows/create-printful-order.js +3 -2
- package/.medusa/server/src/workflows/index.js +5 -2
- package/.medusa/server/src/workflows/sync-products.js +1 -1
- package/README.md +193 -18
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# @legenki/print2medusa
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@legenki/print2medusa)
|
|
4
|
+
[](https://www.npmjs.com/package/@legenki/print2medusa)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
3
7
|
Printful → Medusa v2 plugin: **sync Store Products**, **auto-create Printful orders** on payment capture, and a **Fulfillment Provider** for admin shipping options.
|
|
4
8
|
|
|
5
|
-
MIT licensed.
|
|
9
|
+
Published on npm as [`@legenki/print2medusa`](https://www.npmjs.com/package/@legenki/print2medusa). MIT licensed.
|
|
6
10
|
|
|
7
11
|
## Requirements
|
|
8
12
|
|
|
@@ -16,6 +20,12 @@ MIT licensed.
|
|
|
16
20
|
npm install @legenki/print2medusa
|
|
17
21
|
```
|
|
18
22
|
|
|
23
|
+
Or add it to a Medusa app the plugin-native way:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx medusa plugin:add @legenki/print2medusa
|
|
27
|
+
```
|
|
28
|
+
|
|
19
29
|
Register the plugin and fulfillment provider in `medusa-config.ts`:
|
|
20
30
|
|
|
21
31
|
```ts
|
|
@@ -66,19 +76,175 @@ See `examples/basic-store/` for a fuller snippet.
|
|
|
66
76
|
|
|
67
77
|
## What it does (MVP)
|
|
68
78
|
|
|
69
|
-
| Feature
|
|
70
|
-
|
|
71
|
-
| Product sync
|
|
72
|
-
| Links
|
|
73
|
-
| Orders
|
|
74
|
-
| Fulfillment provider | Select Printful shipping option in Admin locations
|
|
75
|
-
| Status
|
|
79
|
+
| Feature | How |
|
|
80
|
+
| -------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| Product sync | Admin **Sync Now** or `POST /admin/printful/sync` → workflow pulls Printful Sync Products into Medusa |
|
|
82
|
+
| Links | `printful_product_link` / `printful_variant_link` (+ metadata IDs) |
|
|
83
|
+
| Orders | On `payment.captured` → creates Printful order with **`sync_variant_id`** |
|
|
84
|
+
| Fulfillment provider | Select Printful shipping option in Admin locations |
|
|
85
|
+
| Status | `GET /admin/printful/status` + product list widget |
|
|
86
|
+
| Shipment tracking | Printful webhooks → Medusa fulfillment + shipment per parcel, with tracking |
|
|
87
|
+
| Order visibility | Printful status and per-parcel tracking on the Admin order page |
|
|
76
88
|
|
|
77
89
|
### Idempotency
|
|
78
90
|
|
|
79
91
|
- Re-sync updates existing products via link tables (no duplicates) and **upserts variants** — price and assortment changes in Printful reach Medusa; manually-added Medusa variants are left untouched.
|
|
80
92
|
- Concurrent / re-fired payment events will not create a second Printful order: the order is **claimed insert-first** via a unique index on `printful_order_link.medusa_order_id` before the Printful API is called.
|
|
81
93
|
- Shipping `province` is normalized to the 2-letter `state_code` Printful expects for US/CA.
|
|
94
|
+
- Printful redelivers webhooks by design. Each event is stored under a **derived `event_id`** carrying a unique index, so a redelivery is absorbed rather than producing a second fulfillment. Delivery metadata (`retries`, `store`) is excluded from that id — otherwise the same event would hash differently on each attempt.
|
|
95
|
+
- Events for one order are **serialized with a transaction-scoped advisory lock**, so two events cannot both pass the "shipment not yet recorded" check and each create a fulfillment for one parcel.
|
|
96
|
+
|
|
97
|
+
## Webhooks
|
|
98
|
+
|
|
99
|
+
Printful notifies the store of fulfillment progress (`package_shipped`,
|
|
100
|
+
`order_failed`, `order_canceled`, `package_returned`) at:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
POST /hooks/printful/<webhookSecret>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Set the secret as a plugin option, then register the endpoint with Printful:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
options: {
|
|
110
|
+
apiToken: process.env.PRINTFUL_API_TOKEN,
|
|
111
|
+
webhookSecret: process.env.PRINTFUL_WEBHOOK_SECRET, // long, random
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
curl -X POST https://your-store.com/admin/printful/webhook \
|
|
117
|
+
-H 'content-type: application/json' \
|
|
118
|
+
-d '{"base_url":"https://your-store.com"}'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The payload is treated as a **trigger, not a source of truth**: the endpoint
|
|
122
|
+
stores the event, answers `200`, and the workflow re-reads
|
|
123
|
+
`GET /orders/{id}` from Printful for the authoritative state.
|
|
124
|
+
|
|
125
|
+
### The secret is in the URL path
|
|
126
|
+
|
|
127
|
+
Printful API v1's webhook configuration accepts only `url`, `types` and
|
|
128
|
+
`params` — there is no custom-header support — so the shared secret has to
|
|
129
|
+
travel as a path segment. That has consequences worth planning around.
|
|
130
|
+
|
|
131
|
+
**Treat the secret as rotatable, and expect it in access logs.** Any reverse
|
|
132
|
+
proxy, load balancer, or CDN in front of Medusa logs request paths by default,
|
|
133
|
+
and that is entirely outside this plugin's control. Anyone who can read those
|
|
134
|
+
logs can forge webhook deliveries.
|
|
135
|
+
|
|
136
|
+
Mitigations, in rough order of value:
|
|
137
|
+
|
|
138
|
+
- **Scope it.** The secret only authenticates Printful's callback. It grants no
|
|
139
|
+
API access, and because payloads are re-verified against Printful's API, a
|
|
140
|
+
forged delivery cannot invent a shipment — at worst it triggers a redundant
|
|
141
|
+
re-read.
|
|
142
|
+
- **Strip it at the proxy.** If your proxy supports rewriting logged paths, mask
|
|
143
|
+
the segment after `/hooks/printful/`.
|
|
144
|
+
- **Rotate it** on any suspected log exposure, and on staff offboarding.
|
|
145
|
+
|
|
146
|
+
### Rotating the secret
|
|
147
|
+
|
|
148
|
+
1. Change `webhookSecret` to a new random value and restart Medusa.
|
|
149
|
+
2. Re-register with Printful so it stops calling the old URL:
|
|
150
|
+
```bash
|
|
151
|
+
curl -X POST https://your-store.com/admin/printful/webhook \
|
|
152
|
+
-H 'content-type: application/json' \
|
|
153
|
+
-d '{"base_url":"https://your-store.com"}'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Printful keeps **one webhook configuration per store**, so step 2 replaces the
|
|
157
|
+
previous URL outright — the old secret stops being accepted as soon as Medusa
|
|
158
|
+
restarts. Deliveries in flight during the swap are retried by Printful, and
|
|
159
|
+
duplicate events are absorbed by the stored `event_id`, so rotation is safe to
|
|
160
|
+
perform in production.
|
|
161
|
+
|
|
162
|
+
`GET /admin/printful/webhook` shows the registered URL with the secret masked,
|
|
163
|
+
so the admin UI can confirm the configuration without re-exposing the token.
|
|
164
|
+
|
|
165
|
+
### Request logging
|
|
166
|
+
|
|
167
|
+
Errors raised by this route (`404` bad token, `400` malformed payload, `500`
|
|
168
|
+
storage failure) are logged with the secret replaced by `[redacted]`, since
|
|
169
|
+
Medusa's error handler logs the request path verbatim.
|
|
170
|
+
|
|
171
|
+
One gap remains and cannot be closed from plugin code: errors thrown by
|
|
172
|
+
Medusa's **global body parser** — an oversized body or malformed JSON — reach
|
|
173
|
+
the error handler without running any route-scoped middleware, so those log
|
|
174
|
+
lines contain the real path. The endpoint's body limit is therefore raised to
|
|
175
|
+
**1 MB**, well above the largest realistic delivery (a 50-line-item
|
|
176
|
+
`package_shipped` measures ~262 KB; the framework default of 100 KB is in fact
|
|
177
|
+
exceeded by roughly a 25-item order), so genuine Printful traffic does not
|
|
178
|
+
reach that path. This is another reason to treat the secret as rotatable.
|
|
179
|
+
|
|
180
|
+
## Live shipping rates
|
|
181
|
+
|
|
182
|
+
Printful quotes shipping for the destination and cart contents instead of you
|
|
183
|
+
setting a flat price by hand.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
plugins: [
|
|
187
|
+
{
|
|
188
|
+
resolve: "@legenki/print2medusa",
|
|
189
|
+
options: {
|
|
190
|
+
apiToken: process.env.PRINTFUL_API_TOKEN,
|
|
191
|
+
liveShippingRates: true,
|
|
192
|
+
fallbackShippingRates: { STANDARD: 500 }, // minor units
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
modules: [
|
|
197
|
+
{
|
|
198
|
+
resolve: "@medusajs/medusa/fulfillment",
|
|
199
|
+
// Required. The provider reads Printful variant ids from variant metadata
|
|
200
|
+
// through Query, and Medusa only bridges modules a provider declares.
|
|
201
|
+
dependencies: ["query"],
|
|
202
|
+
options: {
|
|
203
|
+
providers: [
|
|
204
|
+
{
|
|
205
|
+
resolve: "@legenki/print2medusa/providers/printful-fulfillment",
|
|
206
|
+
id: "printful",
|
|
207
|
+
options: { apiToken: process.env.PRINTFUL_API_TOKEN },
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**`dependencies: ["query"]` is not optional.** Without it the provider cannot
|
|
216
|
+
resolve Printful variant ids, and every quote quietly falls back to the flat
|
|
217
|
+
rate. Medusa resolves an undeclared dependency to `undefined` rather than
|
|
218
|
+
failing, so the plugin logs an error at startup instead.
|
|
219
|
+
|
|
220
|
+
**Give `fallbackShippingRates` an entry for every method you offer.** A method
|
|
221
|
+
with no entry prices at **zero** rather than blocking checkout: Medusa cannot
|
|
222
|
+
complete a cart whose shipping price fails to resolve, so an underpriced
|
|
223
|
+
delivery is the lesser harm. The plugin logs an error each time it happens.
|
|
224
|
+
|
|
225
|
+
### What happens when Printful is unreachable
|
|
226
|
+
|
|
227
|
+
Checkout still completes. Prices fall back in this order:
|
|
228
|
+
|
|
229
|
+
1. A cached quote inside `shippingRateCacheTtlSeconds` (default 600)
|
|
230
|
+
2. A cached quote past that but within `shippingRateStaleSeconds` (default 86400)
|
|
231
|
+
3. The flat rate from `fallbackShippingRates`
|
|
232
|
+
|
|
233
|
+
A day-old real quote beats a constant someone typed once, which is why the stale
|
|
234
|
+
tier outranks the flat rate. One Printful call serves every shipping option on a
|
|
235
|
+
cart — the whole response is cached, and each option is picked from it locally.
|
|
236
|
+
|
|
237
|
+
### Limits worth knowing
|
|
238
|
+
|
|
239
|
+
- **Printful chooses the shipping method on the order.** The method the customer
|
|
240
|
+
selected is priced correctly but is not passed through to Printful, so it can
|
|
241
|
+
ship by a different service. Medusa does not carry provider data from price
|
|
242
|
+
calculation onto the shipping method, and the mechanism that would fix this
|
|
243
|
+
needs its own release.
|
|
244
|
+
- **Return options are never priced live.** Printful quotes outbound shipping
|
|
245
|
+
only, so a return shipping option must be given a flat admin price.
|
|
246
|
+
- **Rates are quoted in the cart's currency** by asking Printful to convert. If
|
|
247
|
+
a quote comes back in another currency it is discarded rather than converted.
|
|
82
248
|
|
|
83
249
|
## Admin usage
|
|
84
250
|
|
|
@@ -103,24 +269,33 @@ In a host Medusa app:
|
|
|
103
269
|
npx medusa plugin:add @legenki/print2medusa
|
|
104
270
|
```
|
|
105
271
|
|
|
272
|
+
## Roadmap
|
|
273
|
+
|
|
274
|
+
See [ROADMAP.md](./ROADMAP.md) for the planned path from `0.2.0` (webhooks and
|
|
275
|
+
order status) through `1.0.0` (stable API and Printful v2 migration), including
|
|
276
|
+
the testing strategy for each release.
|
|
277
|
+
|
|
106
278
|
## Architecture notes
|
|
107
279
|
|
|
108
280
|
- **Printful is source of truth** for products; Medusa holds a copy + links.
|
|
109
281
|
- Printful API **v1** (`https://api.printful.com`).
|
|
110
282
|
- Long-running sync runs as a Medusa **workflow** (not a blocking HTTP body only—route awaits the workflow today; can be queued later).
|
|
111
|
-
- Webhooks
|
|
283
|
+
- Webhooks carry their secret in the URL path because Printful v1 supports no
|
|
284
|
+
custom headers — see [Webhooks](#webhooks).
|
|
285
|
+
- Multi-store / live rates: planned Phase 2.
|
|
112
286
|
|
|
113
287
|
## Options
|
|
114
288
|
|
|
115
|
-
| Option
|
|
116
|
-
|
|
117
|
-
| `apiToken`
|
|
118
|
-
| `storeId`
|
|
119
|
-
| `autoSubmitOrders`
|
|
120
|
-
| `createOnOrderPlaced` | Also create Printful order on `order.placed`
|
|
121
|
-
| `allowPartialOrders`
|
|
122
|
-
| `markupPercent`
|
|
123
|
-
| `defaultCurrency`
|
|
289
|
+
| Option | Description |
|
|
290
|
+
| --------------------- | ----------------------------------------------------------------------- |
|
|
291
|
+
| `apiToken` | Printful private token (required) |
|
|
292
|
+
| `storeId` | `X-PF-Store-Id` for account-level tokens |
|
|
293
|
+
| `autoSubmitOrders` | Confirm orders for fulfillment (default true) |
|
|
294
|
+
| `createOnOrderPlaced` | Also create Printful order on `order.placed` |
|
|
295
|
+
| `allowPartialOrders` | Allow orders that mix Printful + non-Printful items |
|
|
296
|
+
| `markupPercent` | Markup on retail prices during sync |
|
|
297
|
+
| `defaultCurrency` | Fallback currency code |
|
|
298
|
+
| `webhookSecret` | Shared secret for the Printful webhook path (see [Webhooks](#webhooks)) |
|
|
124
299
|
|
|
125
300
|
## License
|
|
126
301
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@legenki/print2medusa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Printful → Medusa v2 plugin: product sync, auto fulfillment, and admin tools",
|
|
5
5
|
"author": "Andy Legenki",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,8 +42,12 @@
|
|
|
42
42
|
"build": "medusa plugin:build",
|
|
43
43
|
"dev": "medusa plugin:develop",
|
|
44
44
|
"lint": "medusa lint",
|
|
45
|
+
"format": "prettier --write .",
|
|
46
|
+
"format:check": "prettier --check .",
|
|
45
47
|
"typecheck": "tsc --noEmit",
|
|
48
|
+
"typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
|
|
46
49
|
"test": "vitest run",
|
|
50
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
47
51
|
"test:watch": "vitest",
|
|
48
52
|
"prepublishOnly": "medusa plugin:build"
|
|
49
53
|
},
|
|
@@ -62,6 +66,7 @@
|
|
|
62
66
|
"@types/react-dom": "^18.2.25",
|
|
63
67
|
"eslint": "^9.0.0",
|
|
64
68
|
"jiti": "^2.0.0",
|
|
69
|
+
"prettier": "^3.3.0",
|
|
65
70
|
"prop-types": "^15.8.1",
|
|
66
71
|
"react": "^18.2.0",
|
|
67
72
|
"react-dom": "^18.2.0",
|