@legenki/print2medusa 0.1.0 → 0.2.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 +1 -1
- package/.medusa/server/src/subscribers/payment-captured.js +1 -1
- package/.medusa/server/src/utils/mappers.js +67 -20
- package/.medusa/server/src/utils/order-state.js +58 -0
- package/.medusa/server/src/utils/printful-client.js +23 -1
- 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 +124 -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,106 @@ 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.
|
|
82
179
|
|
|
83
180
|
## Admin usage
|
|
84
181
|
|
|
@@ -103,24 +200,33 @@ In a host Medusa app:
|
|
|
103
200
|
npx medusa plugin:add @legenki/print2medusa
|
|
104
201
|
```
|
|
105
202
|
|
|
203
|
+
## Roadmap
|
|
204
|
+
|
|
205
|
+
See [ROADMAP.md](./ROADMAP.md) for the planned path from `0.2.0` (webhooks and
|
|
206
|
+
order status) through `1.0.0` (stable API and Printful v2 migration), including
|
|
207
|
+
the testing strategy for each release.
|
|
208
|
+
|
|
106
209
|
## Architecture notes
|
|
107
210
|
|
|
108
211
|
- **Printful is source of truth** for products; Medusa holds a copy + links.
|
|
109
212
|
- Printful API **v1** (`https://api.printful.com`).
|
|
110
213
|
- 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
|
|
214
|
+
- Webhooks carry their secret in the URL path because Printful v1 supports no
|
|
215
|
+
custom headers — see [Webhooks](#webhooks).
|
|
216
|
+
- Multi-store / live rates: planned Phase 2.
|
|
112
217
|
|
|
113
218
|
## Options
|
|
114
219
|
|
|
115
|
-
| Option
|
|
116
|
-
|
|
117
|
-
| `apiToken`
|
|
118
|
-
| `storeId`
|
|
119
|
-
| `autoSubmitOrders`
|
|
120
|
-
| `createOnOrderPlaced` | Also create Printful order on `order.placed`
|
|
121
|
-
| `allowPartialOrders`
|
|
122
|
-
| `markupPercent`
|
|
123
|
-
| `defaultCurrency`
|
|
220
|
+
| Option | Description |
|
|
221
|
+
| --------------------- | ----------------------------------------------------------------------- |
|
|
222
|
+
| `apiToken` | Printful private token (required) |
|
|
223
|
+
| `storeId` | `X-PF-Store-Id` for account-level tokens |
|
|
224
|
+
| `autoSubmitOrders` | Confirm orders for fulfillment (default true) |
|
|
225
|
+
| `createOnOrderPlaced` | Also create Printful order on `order.placed` |
|
|
226
|
+
| `allowPartialOrders` | Allow orders that mix Printful + non-Printful items |
|
|
227
|
+
| `markupPercent` | Markup on retail prices during sync |
|
|
228
|
+
| `defaultCurrency` | Fallback currency code |
|
|
229
|
+
| `webhookSecret` | Shared secret for the Printful webhook path (see [Webhooks](#webhooks)) |
|
|
124
230
|
|
|
125
231
|
## License
|
|
126
232
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@legenki/print2medusa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|