@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/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # @legenki/print2medusa
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@legenki/print2medusa.svg)](https://www.npmjs.com/package/@legenki/print2medusa)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@legenki/print2medusa.svg)](https://www.npmjs.com/package/@legenki/print2medusa)
5
+ [![license](https://img.shields.io/npm/l/@legenki/print2medusa.svg)](./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 | How |
70
- |--------|-----|
71
- | Product sync | Admin **Sync Now** or `POST /admin/printful/sync` → workflow pulls Printful Sync Products into Medusa |
72
- | Links | `printful_product_link` / `printful_variant_link` (+ metadata IDs) |
73
- | Orders | On `payment.captured` → creates Printful order with **`sync_variant_id`** |
74
- | Fulfillment provider | Select Printful shipping option in Admin locations |
75
- | Status | `GET /admin/printful/status` + product list widget |
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 / multi-store / live rates: planned Phase 2.
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 | Description |
116
- |--------|-------------|
117
- | `apiToken` | Printful private token (required) |
118
- | `storeId` | `X-PF-Store-Id` for account-level tokens |
119
- | `autoSubmitOrders` | Confirm orders for fulfillment (default true) |
120
- | `createOnOrderPlaced` | Also create Printful order on `order.placed` |
121
- | `allowPartialOrders` | Allow orders that mix Printful + non-Printful items |
122
- | `markupPercent` | Markup on retail prices during sync |
123
- | `defaultCurrency` | Fallback currency code |
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.1.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",