medusa-plugin-ordinant 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 +1636 -0
- package/.medusa/server/src/admin/index.mjs +1635 -0
- package/.medusa/server/src/api/admin/ordinant/classifications/[sku]/confirm/route.js +25 -0
- package/.medusa/server/src/api/admin/ordinant/classifications/bulk-confirm/route.js +29 -0
- package/.medusa/server/src/api/admin/ordinant/classifications/route.js +23 -0
- package/.medusa/server/src/api/admin/ordinant/classify-all/route.js +108 -0
- package/.medusa/server/src/api/admin/ordinant/dictionary/route.js +17 -0
- package/.medusa/server/src/api/admin/ordinant/generate-all-skus/route.js +31 -0
- package/.medusa/server/src/api/admin/ordinant/overview/route.js +69 -0
- package/.medusa/server/src/api/admin/ordinant/products/[id]/classify/route.js +18 -0
- package/.medusa/server/src/api/admin/ordinant/products/[id]/generate-skus/route.js +34 -0
- package/.medusa/server/src/api/admin/ordinant/products/[id]/overview/route.js +59 -0
- package/.medusa/server/src/api/admin/ordinant/stats/route.js +17 -0
- package/.medusa/server/src/api/admin/ordinant/status/route.js +52 -0
- package/.medusa/server/src/api/middlewares.js +14 -0
- package/.medusa/server/src/api/store/ordinant/preview/route.js +110 -0
- package/.medusa/server/src/api/store/ordinant/preview/validators.js +34 -0
- package/.medusa/server/src/lib/client.js +136 -0
- package/.medusa/server/src/modules/ordinant/index.js +14 -0
- package/.medusa/server/src/modules/ordinant/service.js +27 -0
- package/.medusa/server/src/storefront.d.ts +61 -0
- package/.medusa/server/src/storefront.js +61 -0
- package/.medusa/server/src/subscribers/product-upsert.js +52 -0
- package/.medusa/server/src/workflows/bulk-operations.js +59 -0
- package/.medusa/server/src/workflows/classify-product.js +80 -0
- package/.medusa/server/src/workflows/confirm-classification.js +20 -0
- package/.medusa/server/src/workflows/generate-skus.js +81 -0
- package/.medusa/server/src/workflows/hooks/validate-cart-completion.js +127 -0
- package/README.md +495 -0
- package/package.json +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# medusa-plugin-ordinant
|
|
2
|
+
|
|
3
|
+
Ordinant compliance for Medusa v2: AI-proposed product classification with
|
|
4
|
+
merchant attestation, and a deterministic, citation-bearing checkout gate.
|
|
5
|
+
|
|
6
|
+
> **Ordinant is a compliance information and automation service — not legal
|
|
7
|
+
> advice.** No attorney–client relationship is created by its use. Merchants
|
|
8
|
+
> attest to the final classification of their products.
|
|
9
|
+
|
|
10
|
+
## Contents
|
|
11
|
+
|
|
12
|
+
1. [What it does](#what-it-does)
|
|
13
|
+
2. [Requirements](#requirements)
|
|
14
|
+
3. [Installation](#installation)
|
|
15
|
+
4. [Configuration](#configuration)
|
|
16
|
+
5. [How it works](#how-it-works)
|
|
17
|
+
6. [The admin dashboard](#the-admin-dashboard)
|
|
18
|
+
7. [The per-product widget](#the-per-product-widget)
|
|
19
|
+
8. [The checkout gate](#the-checkout-gate)
|
|
20
|
+
9. [Buyer verification assertions](#buyer-verification-assertions)
|
|
21
|
+
10. [Recommended: early compliance preview](#recommended-early-compliance-preview-progressive-checkout-ux)
|
|
22
|
+
11. [Onboarding an existing catalog](#onboarding-an-existing-catalog)
|
|
23
|
+
12. [Admin API reference](#admin-api-reference)
|
|
24
|
+
13. [Store API reference](#store-api-reference)
|
|
25
|
+
14. [Troubleshooting](#troubleshooting)
|
|
26
|
+
15. [Local development](#local-development)
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
|
|
30
|
+
| Moment | What happens |
|
|
31
|
+
|---|---|
|
|
32
|
+
| Product created or updated | Variants are sent to Ordinant; an LLM proposes dictionary attributes with a confidence score |
|
|
33
|
+
| Merchant opens the product page | The **Ordinant compliance** widget shows each SKU's status; the merchant confirms or edits the proposal — recording their **attestation** |
|
|
34
|
+
| Merchant opens the Ordinant dashboard | A catalog-wide triage queue: an "illegal orders stopped" impact metric, and bulk SKU-generation + classification + confirmation |
|
|
35
|
+
| Buyer completes checkout | Every line item is decided against the destination's rules; blocks and unmet requirements stop the order with statute citations in the error message |
|
|
36
|
+
|
|
37
|
+
Three properties worth knowing up front:
|
|
38
|
+
|
|
39
|
+
- **No storefront changes required for the gate.** It runs inside the backend's
|
|
40
|
+
cart completion workflow; failures surface through the standard checkout error
|
|
41
|
+
path every storefront already renders. (An optional early-preview UX can be
|
|
42
|
+
wired in, and recommended — see [Recommended: early compliance preview](#recommended-early-compliance-preview-progressive-checkout-ux).)
|
|
43
|
+
- **Fail closed, always.** Unclassified SKUs cannot sell. An unreachable
|
|
44
|
+
decision engine blocks checkout (configurable, see `failMode`). A wrong
|
|
45
|
+
"allow" never happens silently.
|
|
46
|
+
- **The plugin holds no compliance logic.** Decisions are made, logged, and
|
|
47
|
+
replayable on the Ordinant server; the plugin carries data and citations.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Medusa **v2.4+** (built and tested against 2.17)
|
|
52
|
+
- Node.js **20+**
|
|
53
|
+
- A running Ordinant deployment and a merchant API key
|
|
54
|
+
- Ordinant's catalog-classification module enabled server-side
|
|
55
|
+
(`ORDINANT_CLASSIFY_ENABLED=true` on the Ordinant deployment) if you want
|
|
56
|
+
automatic proposals — the widget and gate work without it, but proposals
|
|
57
|
+
must then be created via Ordinant's API directly
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
### 1. Install the package
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install medusa-plugin-ordinant
|
|
65
|
+
# or: yarn add medusa-plugin-ordinant / pnpm add medusa-plugin-ordinant
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> **pnpm users:** ensure `@tanstack/react-query` matches the version used by
|
|
69
|
+
> `@medusajs/dashboard` (see Medusa's admin-customization docs) or the admin
|
|
70
|
+
> widget will fail to resolve its peer dependency.
|
|
71
|
+
|
|
72
|
+
### 2. Register the plugin
|
|
73
|
+
|
|
74
|
+
In `medusa-config.ts`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
module.exports = defineConfig({
|
|
78
|
+
projectConfig: {
|
|
79
|
+
// ... your existing config
|
|
80
|
+
},
|
|
81
|
+
plugins: [
|
|
82
|
+
{
|
|
83
|
+
resolve: "medusa-plugin-ordinant",
|
|
84
|
+
options: {
|
|
85
|
+
baseUrl: process.env.ORDINANT_BASE_URL,
|
|
86
|
+
apiKey: process.env.ORDINANT_API_KEY,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3. Set environment variables
|
|
94
|
+
|
|
95
|
+
In the backend's `.env`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
ORDINANT_BASE_URL=https://your-ordinant-deployment.example
|
|
99
|
+
ORDINANT_API_KEY=ord_live_...
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The API key is a **merchant** key issued by your Ordinant deployment
|
|
103
|
+
(`ORDINANT_API_KEYS` on the server). It never reaches the browser: the admin
|
|
104
|
+
widget talks to plugin-provided backend routes, which call Ordinant
|
|
105
|
+
server-side.
|
|
106
|
+
|
|
107
|
+
### 4. Restart and verify
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm run dev
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If `baseUrl` or `apiKey` is missing the plugin **does not crash** — it boots
|
|
114
|
+
into a degraded, un-activated state: the **Ordinant** dashboard shows an
|
|
115
|
+
[activation gate](#the-admin-dashboard) instead of data, and the checkout gate
|
|
116
|
+
treats the engine as unreachable (honoring `failMode`). A half-configured
|
|
117
|
+
install never takes down the backend.
|
|
118
|
+
|
|
119
|
+
To verify a *good* connection, open the **Ordinant** page in the admin sidebar:
|
|
120
|
+
the header's engine-health pill should read **"Engine connected · `<ruleset
|
|
121
|
+
version>`"** and your catalog should load.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
All plugin options:
|
|
126
|
+
|
|
127
|
+
| Option | Required | Default | Purpose |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| `baseUrl` | ✅ | — | Ordinant API base URL |
|
|
130
|
+
| `apiKey` | ✅ | — | Merchant bearer key |
|
|
131
|
+
| `timeoutMs` | — | `5000` | Per-request timeout; checkout must not hang |
|
|
132
|
+
| `failMode` | — | `"closed"` | Gate behavior when Ordinant is unreachable: `"closed"` blocks completion; `"open"` completes the order and logs an error. Choose `"open"` only as a deliberate, documented risk decision — it means orders ship unchecked during an outage. |
|
|
133
|
+
| `skuMode` | — | `"block"` | What the gate does with a cart line that has **no SKU**. Ordinant identifies (and gates) products by SKU, so a SKU-less item can't be checked. `"block"` fails closed — checkout stops with a message to assign a SKU. `"allow"` skips SKU-less items; choose it only if you knowingly sell unregulated products without SKUs (gift cards, services) and accept they are never gated. |
|
|
134
|
+
|
|
135
|
+
## How it works
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
product.created / product.updated
|
|
139
|
+
│ (subscriber)
|
|
140
|
+
▼
|
|
141
|
+
classifyProductWorkflow ──► POST {ordinant}/v1/catalog/classify
|
|
142
|
+
│ LLM proposes attributes + confidence
|
|
143
|
+
▼
|
|
144
|
+
proposal stored on Ordinant (status: proposed — cannot sell yet)
|
|
145
|
+
│
|
|
146
|
+
▼ merchant reviews in the product-page widget
|
|
147
|
+
POST /admin/ordinant/classifications/{sku}/confirm
|
|
148
|
+
│ attestation recorded (merchant is the classifier of record)
|
|
149
|
+
▼
|
|
150
|
+
SKU is confirmed — checkout decisions now resolve its attributes
|
|
151
|
+
│
|
|
152
|
+
▼ buyer completes checkout
|
|
153
|
+
completeCartWorkflow.hooks.validate
|
|
154
|
+
└─► POST {ordinant}/v1/decisions (bare SKUs + destination + buyer)
|
|
155
|
+
ALLOW → order places
|
|
156
|
+
CONDITIONAL/BLOCK → completion stops, citations in the error
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Classification failures (Ordinant down, module disabled, odd product data)
|
|
160
|
+
are logged and swallowed — catalog work is never disrupted. The SKU simply
|
|
161
|
+
stays unclassified, which the gate fails closed on.
|
|
162
|
+
|
|
163
|
+
## The admin dashboard
|
|
164
|
+
|
|
165
|
+
A dedicated **Ordinant** page in the admin sidebar (its icon swaps to your logo
|
|
166
|
+
— see [Branding](#branding)) is the catalog-wide cockpit the per-product widget
|
|
167
|
+
can't give you. It's built as a *triage queue*: it surfaces what needs action
|
|
168
|
+
first, not a wall of everything.
|
|
169
|
+
|
|
170
|
+
- **Activation gate.** Until a valid API key is configured, the whole page is
|
|
171
|
+
blurred behind a lock card linking to your Ordinant sign-up — an un-activated
|
|
172
|
+
install looks intentional, not broken.
|
|
173
|
+
- **Welcome header + engine-health pill.** A greeting (by the signed-in admin's
|
|
174
|
+
first name) and a live status pill — **Engine connected · `<ruleset
|
|
175
|
+
version>`**, **Engine unreachable**, or **Not activated** — from
|
|
176
|
+
`GET /admin/ordinant/status`.
|
|
177
|
+
- **Impact hero — "Illegal orders stopped."** The headline metric, from
|
|
178
|
+
`GET /admin/ordinant/stats`: an animated count of blocked orders with a
|
|
179
|
+
**Day / Month / Year / All** toggle, a trend chart, and KPI cards (all-time
|
|
180
|
+
stopped, top blocked destination, stopped this year). Before the first block
|
|
181
|
+
it reads "You're protected — no illegal orders yet" rather than a bare zero.
|
|
182
|
+
- **Filter tiles.** Click to filter: **Needs attention** (the default —
|
|
183
|
+
everything actionable: needs-SKU + unclassified + to-review), **Needs SKU**,
|
|
184
|
+
**Unclassified**, **To review**, **Confirmed**, **All**. A caught-up queue
|
|
185
|
+
shows a calm "You're all caught up — N SKUs protected" state.
|
|
186
|
+
- **Search + pagination.** Filter by product title or SKU. The queue views
|
|
187
|
+
paginate product cards; **All** / **Confirmed** switch to a dense, paginated
|
|
188
|
+
table (editing a row there opens a side drawer) so hundreds of SKUs stay
|
|
189
|
+
scannable. Filter, search, and page are kept in the URL — shareable and
|
|
190
|
+
back-button friendly.
|
|
191
|
+
- **Bulk actions** (top-right, shown only when relevant):
|
|
192
|
+
- **Generate N SKUs** — assign readable, collision-free SKUs to every SKU-less
|
|
193
|
+
variant, then auto-classify them: one hands-off action with a live progress
|
|
194
|
+
bar.
|
|
195
|
+
- **Classify N** — classify the unclassified backlog in chunks.
|
|
196
|
+
- **Confirm N ready** — attest high-confidence proposals (≥ 85%) in one go.
|
|
197
|
+
Because every confirmation is a legal attestation, the dialog shows a
|
|
198
|
+
**category-grouped breakdown of exactly what you're attesting** ("212
|
|
199
|
+
accessories, 40 magazines, 12 handguns"), not a bare count.
|
|
200
|
+
|
|
201
|
+
Attributes are shown as plain-language chips (`Magazine · 30 rounds · non-NFA`),
|
|
202
|
+
never raw JSON. Typical onboarding: open the page → **Generate N SKUs** →
|
|
203
|
+
**Classify N** → **Confirm N ready** for the confident batch → review the handful
|
|
204
|
+
that need judgment with the dropdown editor.
|
|
205
|
+
|
|
206
|
+
### Branding
|
|
207
|
+
|
|
208
|
+
Drop your company media in one place — `src/admin/components/brand-assets.tsx`:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
export const BRAND = {
|
|
212
|
+
name: "Ordinant",
|
|
213
|
+
logo: "", // square logo (data: URI or imported asset) → sidebar icon, gate, empty-state marks
|
|
214
|
+
wordmark: "", // optional horizontal wordmark → welcome header
|
|
215
|
+
website: "https://ordinant.com", // gate CTA target
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Set `logo` and the sidebar icon, activation gate, and empty-state marks all pick
|
|
220
|
+
it up; leave it empty to use the built-in shield. These are build-time constants,
|
|
221
|
+
so set them before building the plugin.
|
|
222
|
+
|
|
223
|
+
## The per-product widget
|
|
224
|
+
|
|
225
|
+
On every product page (`product.details.after` zone):
|
|
226
|
+
|
|
227
|
+
- **Per-SKU status**: `unclassified`, `proposed · NN%` (hover for the model's
|
|
228
|
+
notes), or `confirmed` (hover for the attestation timestamp).
|
|
229
|
+
- **Generate SKUs** for SKU-less variants — the same helper as the dashboard.
|
|
230
|
+
- **Confirm** accepts the proposal as-is. **Edit** opens a **dropdown/checkbox
|
|
231
|
+
attribute editor generated from the compliance dictionary** — never raw JSON —
|
|
232
|
+
so the confirmed set is *your* assertion, corrected in a guided form.
|
|
233
|
+
- **Classify** (re-)submits the product's variants — use it for products created
|
|
234
|
+
before the plugin was installed, or after editing product data.
|
|
235
|
+
- Invalid attribute edits are rejected by Ordinant's dictionary validation and
|
|
236
|
+
shown inline — garbage can't be attested.
|
|
237
|
+
|
|
238
|
+
**Category-adaptive fields.** The editor shows only the attributes relevant to
|
|
239
|
+
the chosen category — pick **optic** and you won't see magazine capacity, action
|
|
240
|
+
type, or NFA class; pick **knife** and you get blade type and length. This is
|
|
241
|
+
built into the plugin (a category → fields map) and also honors the engine
|
|
242
|
+
dictionary's `applies_to` when present, so it's correct without waiting on a
|
|
243
|
+
dictionary change.
|
|
244
|
+
|
|
245
|
+
Re-classifying a SKU (e.g. after a product edit) resets it to `proposed`: a
|
|
246
|
+
stale attestation never survives changed product data.
|
|
247
|
+
|
|
248
|
+
## The checkout gate
|
|
249
|
+
|
|
250
|
+
Consumes `completeCartWorkflow.hooks.validate`, which runs before any order
|
|
251
|
+
is created. Semantics:
|
|
252
|
+
|
|
253
|
+
- **Items**: every cart line is checked by its `variant_sku`. A line with
|
|
254
|
+
**no SKU** cannot be identified or gated, so it **fails closed** (blocks
|
|
255
|
+
checkout) by default — silently skipping it would be a fail-open hole on a
|
|
256
|
+
compliance gate. Give every sellable variant a SKU; or set
|
|
257
|
+
`skuMode: "allow"` if you knowingly sell unregulated SKU-less items and
|
|
258
|
+
accept they go ungated. (In Medusa every product has at least one variant,
|
|
259
|
+
but a variant's SKU is optional — this is about SKU presence, not variant
|
|
260
|
+
count.)
|
|
261
|
+
- **Bare-SKU decisions**: the gate sends empty attributes; Ordinant resolves
|
|
262
|
+
the merchant's *confirmed* classifications server-side. Proposed-but-
|
|
263
|
+
unconfirmed classifications are never used.
|
|
264
|
+
- **Destination**: from the cart's shipping address (`province` = US state).
|
|
265
|
+
A missing state blocks completion with a clear message. Non-US
|
|
266
|
+
destinations skip the gate entirely (Ordinant is US-only in v1) with a
|
|
267
|
+
warning log.
|
|
268
|
+
- **Outcomes**: `ALLOW` → order proceeds (the decision ID and ruleset
|
|
269
|
+
version are logged for audit). `BLOCK` / unmet `CONDITIONAL` → a
|
|
270
|
+
`MedusaError` stops completion; the message lists each problem SKU with
|
|
271
|
+
its requirement or prohibition **and the statute citations**. Storefronts
|
|
272
|
+
render this through their normal checkout error handling — the Next.js
|
|
273
|
+
starter shows it on the payment step with no modification.
|
|
274
|
+
- **Idempotency**: decisions are keyed `medusa-cart:{cart_id}`, so checkout
|
|
275
|
+
retries never double-log on the Ordinant side.
|
|
276
|
+
- **Unclassified SKUs** produce a distinct message ("product is not yet
|
|
277
|
+
classified for compliance") so merchants immediately know it's an
|
|
278
|
+
onboarding gap, not a legal block.
|
|
279
|
+
|
|
280
|
+
## Buyer verification assertions
|
|
281
|
+
|
|
282
|
+
Conditional effects (age gates, FFL routing, permits) are satisfied by
|
|
283
|
+
verification *assertions* — Ordinant never receives identity documents.
|
|
284
|
+
Upstream integrations (BlueCheck/AgeChecker-style age verification, FFL
|
|
285
|
+
selection UIs) should write their results to `cart.metadata.ordinant_buyer`
|
|
286
|
+
before completion:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{
|
|
290
|
+
"age_verified": true,
|
|
291
|
+
"verified_age": 27,
|
|
292
|
+
"ffl_verified": true,
|
|
293
|
+
"sot_verified": false,
|
|
294
|
+
"permits": ["il_foid"],
|
|
295
|
+
"fulfillment_method": "ship"
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Until such an integration exists, conditional requirements simply block
|
|
300
|
+
completion with a message stating exactly what's missing (e.g. "route
|
|
301
|
+
shipment to a verified FFL [18 U.S.C. § 922(b)(3)]") — which is the correct
|
|
302
|
+
fail-safe default.
|
|
303
|
+
|
|
304
|
+
## Recommended: early compliance preview (progressive checkout UX)
|
|
305
|
+
|
|
306
|
+
> **Strongly recommended for every storefront.** It's a few lines to wire, it
|
|
307
|
+
> can't break checkout (fail-open, backstopped by the mandatory gate), and it
|
|
308
|
+
> transforms the experience: the buyer learns their order can't ship to their
|
|
309
|
+
> state — *with the statute citation* — the instant they enter the address,
|
|
310
|
+
> right where they can fix it, instead of after they've entered a card at
|
|
311
|
+
> "Place Order". This is the difference between compliance that feels like a
|
|
312
|
+
> dead end and compliance that feels helpful.
|
|
313
|
+
|
|
314
|
+
The checkout gate above is the **authoritative, non-bypassable** enforcement
|
|
315
|
+
point: it runs server-side at cart completion and stops non-compliant orders no
|
|
316
|
+
matter what the storefront does. That is mandatory and needs zero storefront
|
|
317
|
+
work.
|
|
318
|
+
|
|
319
|
+
Surfacing a problem *earlier* — the moment a buyer picks a shipping state
|
|
320
|
+
instead of at "Place Order" — is the recommended UX layer on top. The plugin
|
|
321
|
+
exposes an advisory, **non-logging** preview for exactly this:
|
|
322
|
+
|
|
323
|
+
- **`POST /store/ordinant/preview`** (storefront, publishable key) — body
|
|
324
|
+
`{ "cart_id": "...", "destination"?: { "state", ... }, "buyer"?: { ... } }`.
|
|
325
|
+
It resolves the cart's items server-side; the destination comes from the
|
|
326
|
+
optional `destination` (a proposed, not-yet-saved address) or the cart's saved
|
|
327
|
+
shipping address. Returns what the gate *would* decide. It **never blocks and
|
|
328
|
+
fails open** — it is purely advisory.
|
|
329
|
+
- It proxies to Ordinant's `POST /v1/decisions/preview`, which is non-logging by
|
|
330
|
+
default so re-previews don't inflate anything — **with one deliberate
|
|
331
|
+
exception:** a **BLOCK** is recorded **once per cart** (deduped by an
|
|
332
|
+
idempotency key the store route sends). A buyer turned away at the address
|
|
333
|
+
step is a real "stopped illegal order," so it counts toward the merchant's
|
|
334
|
+
blocked-order metric — but repeat previews of the same cart never double-count,
|
|
335
|
+
and ALLOW/CONDITIONAL previews are never logged.
|
|
336
|
+
|
|
337
|
+
Two rules keep this safe:
|
|
338
|
+
|
|
339
|
+
1. **The preview is advisory; the completeCart gate is the guarantee.** Client
|
|
340
|
+
checks are bypassable, so they can never be the enforcement point. Always
|
|
341
|
+
keep the gate — the preview only *complements* it.
|
|
342
|
+
2. **Surface each requirement where its input appears.** The destination verdict
|
|
343
|
+
(PII-free) resolves as soon as the shipping state is known — the
|
|
344
|
+
highest-value early signal, and it fires *before* payment authorization, so
|
|
345
|
+
you never authorize a card you're about to block. Age/FFL requirements
|
|
346
|
+
surface at their own steps once those assertions exist.
|
|
347
|
+
|
|
348
|
+
### Wiring it into your checkout
|
|
349
|
+
|
|
350
|
+
Two small steps — the same everywhere; only *where* you hook in differs.
|
|
351
|
+
|
|
352
|
+
**Step 1 — find where your checkout saves the shipping address.** That's the
|
|
353
|
+
action behind your "Continue to delivery" (or equivalent) button.
|
|
354
|
+
|
|
355
|
+
| Storefront | Hook point |
|
|
356
|
+
|---|---|
|
|
357
|
+
| Medusa **Next.js starter** | the `setAddresses` server action in `src/lib/data/cart.ts` |
|
|
358
|
+
| Any custom storefront | wherever you call `sdk.store.cart.update({ shipping_address })` (i.e. `POST /store/carts/{id}`) to save the address |
|
|
359
|
+
|
|
360
|
+
**Step 2 — check the proposed address *before* you save it, and stop if it's
|
|
361
|
+
blocked.** Import the helper the plugin ships:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
|
|
365
|
+
|
|
366
|
+
// in your address-submit handler, BEFORE you persist the address:
|
|
367
|
+
const block = await checkAddressCompliance(sdk, {
|
|
368
|
+
cartId,
|
|
369
|
+
destination: { state, country_code }, // the values the buyer just entered
|
|
370
|
+
})
|
|
371
|
+
if (block) {
|
|
372
|
+
return block // show `block`; do NOT save the address
|
|
373
|
+
}
|
|
374
|
+
await saveTheAddress() // your existing cart update
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
- `sdk` is your Medusa JS SDK (anything with a `client.fetch`).
|
|
378
|
+
- `block` is a ready-to-display message with statute citations, or `null`.
|
|
379
|
+
- **Fail-open**: any error, or an unconfigured plugin, returns `null` — it can
|
|
380
|
+
never break checkout. Only a hard **BLOCK** returns a message; a `CONDITIONAL`
|
|
381
|
+
(age/FFL not gathered yet) returns `null` and proceeds to the next step.
|
|
382
|
+
- Checking the **proposed** `destination` *before* saving means a blocked
|
|
383
|
+
address is never written to the cart — a buyer who backs out and re-enters
|
|
384
|
+
checkout won't find a restricted address waiting.
|
|
385
|
+
|
|
386
|
+
**Display:** render the returned string wherever your form shows errors. In the
|
|
387
|
+
Next.js starter, `setAddresses` already returns its errors to an
|
|
388
|
+
`<ErrorMessage>`, so you just `return block` — no component change:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
// src/lib/data/cart.ts
|
|
392
|
+
import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
|
|
393
|
+
|
|
394
|
+
export async function setAddresses(currentState: unknown, formData: FormData) {
|
|
395
|
+
try {
|
|
396
|
+
// ...parse the form into `data`...
|
|
397
|
+
const block = await checkAddressCompliance(sdk, {
|
|
398
|
+
cartId,
|
|
399
|
+
destination: {
|
|
400
|
+
state: String(formData.get("shipping_address.province") ?? ""),
|
|
401
|
+
country_code: String(formData.get("shipping_address.country_code") ?? ""),
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
if (block) return block // stops the redirect; shown by <ErrorMessage>
|
|
405
|
+
await updateCart(data) // save only when clear
|
|
406
|
+
} catch (e: any) {
|
|
407
|
+
return e.message
|
|
408
|
+
}
|
|
409
|
+
redirect(`/${countryCode}/checkout?step=delivery`)
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Prefer not to add the dependency? `checkAddressCompliance` is a single,
|
|
414
|
+
self-contained function — copy it from `src/storefront.ts` into your codebase
|
|
415
|
+
instead. Either way, anything you don't wire is still caught by the completeCart
|
|
416
|
+
gate; the preview is pure upside.
|
|
417
|
+
|
|
418
|
+
## Onboarding an existing catalog
|
|
419
|
+
|
|
420
|
+
Products created **after** installation classify automatically. For the
|
|
421
|
+
existing catalog:
|
|
422
|
+
|
|
423
|
+
- Small catalogs: open each product and click **Classify**.
|
|
424
|
+
- Bulk: call Ordinant's `POST /v1/catalog/classify` directly with your
|
|
425
|
+
merchant key, in chunks of **~100 SKUs per request** (see the Ordinant API
|
|
426
|
+
reference for the payload). Then confirm in the widget, or via
|
|
427
|
+
`POST /v1/catalog/classifications/{sku}/confirm` for programmatic
|
|
428
|
+
confirmation of high-confidence proposals — remembering that confirmation
|
|
429
|
+
is your attestation.
|
|
430
|
+
|
|
431
|
+
**Classify everything, including non-regulated products** (t-shirts and
|
|
432
|
+
cleaning kits classify as `accessory`): the gate fails closed on
|
|
433
|
+
unclassified SKUs by design. An unclassified hoodie blocking checkout is an
|
|
434
|
+
onboarding gap; a misclassified suppressor shipping is a felony. The design
|
|
435
|
+
prefers the first failure.
|
|
436
|
+
|
|
437
|
+
## Admin API reference
|
|
438
|
+
|
|
439
|
+
Routes the plugin adds to your Medusa backend (admin-authenticated;
|
|
440
|
+
they proxy to Ordinant server-side):
|
|
441
|
+
|
|
442
|
+
| Route | Purpose |
|
|
443
|
+
|---|---|
|
|
444
|
+
| `GET /admin/ordinant/overview` | Every store variant × classification state (incl. `needs_sku`) + summary counts |
|
|
445
|
+
| `GET /admin/ordinant/products/{id}/overview` | Per-product version of the above (the widget's display query) |
|
|
446
|
+
| `GET /admin/ordinant/dictionary` | The attribute dictionary the editor renders its dropdowns from |
|
|
447
|
+
| `POST /admin/ordinant/products/{id}/generate-skus` | Assign readable, collision-free SKUs to a product's SKU-less variants |
|
|
448
|
+
| `POST /admin/ordinant/classify-all` | Classify the next chunk of unclassified SKUs; returns `remaining` so the caller loops |
|
|
449
|
+
| `POST /admin/ordinant/classifications/bulk-confirm` | Attest a batch of proposals as-is; body `{"skus": [...]}` |
|
|
450
|
+
| `GET /admin/ordinant/classifications?skus=a,b,c` | Classification records, optionally filtered to specific SKUs (the widget's display query) |
|
|
451
|
+
| `POST /admin/ordinant/classifications/{sku}/confirm` | Record the merchant's attestation; body `{"attributes": {...}}` |
|
|
452
|
+
| `POST /admin/ordinant/products/{id}/classify` | (Re-)classify a product's variants on demand |
|
|
453
|
+
| `GET /admin/ordinant/status` | Readiness for the dashboard: `configured`, `engine_reachable`, `ruleset_version` |
|
|
454
|
+
| `GET /admin/ordinant/stats` | Blocked-order metrics ("stopped illegal orders") for the impact hero |
|
|
455
|
+
|
|
456
|
+
## Store API reference
|
|
457
|
+
|
|
458
|
+
Routes the plugin adds for the **storefront** (publishable-key auth, guest-safe):
|
|
459
|
+
|
|
460
|
+
| Route | Purpose |
|
|
461
|
+
|---|---|
|
|
462
|
+
| `POST /store/ordinant/preview` | Advisory, non-logging compliance preview for a cart; body `{"cart_id": "...", "destination"?: {...}, "buyer"?: {...}}` — optional `destination` vets a proposed address before it's saved. Never blocks, fails open. See [Recommended: early compliance preview](#recommended-early-compliance-preview-progressive-checkout-ux). |
|
|
463
|
+
|
|
464
|
+
## Troubleshooting
|
|
465
|
+
|
|
466
|
+
| Symptom | Cause / fix |
|
|
467
|
+
|---|---|
|
|
468
|
+
| Dashboard shows the activation gate / health pill reads "Not activated" | `ORDINANT_BASE_URL` / `ORDINANT_API_KEY` are unset or empty — set them and pass them in the plugin options, then restart. The plugin degrades gracefully rather than crashing when unconfigured |
|
|
469
|
+
| Health pill reads "Engine unreachable" or widget shows "Could not reach Ordinant" | Ordinant deployment down or wrong `baseUrl`; check backend logs for the underlying error |
|
|
470
|
+
| Widget loads but classify returns an error | Ordinant's classification module is disabled server-side — set `ORDINANT_CLASSIFY_ENABLED=true` + `ANTHROPIC_API_KEY` on the **Ordinant** deployment (not in Medusa) |
|
|
471
|
+
| Every checkout is blocked with "not yet classified" | Working as designed — confirm classifications for every sellable SKU, including non-regulated ones |
|
|
472
|
+
| Checkout blocked with "Compliance check is temporarily unavailable" | Ordinant unreachable and `failMode` is `closed` (default). Restore connectivity; only set `failMode: "open"` if you accept unchecked orders during outages |
|
|
473
|
+
| Checkout blocked with a permit/FFL/age requirement | Correct legal outcome for that destination — the citation is in the message. Recovering these sales requires the corresponding verification integration writing `cart.metadata.ordinant_buyer` |
|
|
474
|
+
| 401s from Ordinant in logs | Wrong or rotated merchant key |
|
|
475
|
+
| Gate never fires | Cart items have no `variant_sku`, or the destination isn't US |
|
|
476
|
+
|
|
477
|
+
## Local development
|
|
478
|
+
|
|
479
|
+
This plugin lives in a workspace inside the `ordinant-test` monorepo:
|
|
480
|
+
|
|
481
|
+
```bash
|
|
482
|
+
# build the plugin (server + admin extensions)
|
|
483
|
+
cd plugins/medusa-plugin-ordinant && npm run build
|
|
484
|
+
|
|
485
|
+
# run the backend against it (workspace-linked automatically)
|
|
486
|
+
cd apps/backend && npm run dev
|
|
487
|
+
|
|
488
|
+
# live-reload plugin development (publishes to the local yalc registry)
|
|
489
|
+
cd plugins/medusa-plugin-ordinant && npm run dev
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Point `ORDINANT_BASE_URL` at a local engine (`go run ./cmd/ordinantd` in the
|
|
493
|
+
Ordinant repo, default `http://localhost:8080`) for a fully local loop.
|
|
494
|
+
|
|
495
|
+
To publish: `npm publish` (the `prepublishOnly` hook builds `.medusa/server`).
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "medusa-plugin-ordinant",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ordinant compliance for Medusa v2: catalog classification with merchant attestation and a deterministic, citation-bearing checkout gate. Compliance information and automation - not legal advice.",
|
|
5
|
+
"author": "Ordinant (https://ordinant.com)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://ordinant.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Kaelbroersma/medusa-ordinant-plugin.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://ordinant.com"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
".medusa/server"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./workflows": "./.medusa/server/src/workflows/index.js",
|
|
21
|
+
"./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
|
|
22
|
+
"./modules/*": "./.medusa/server/src/modules/*/index.js",
|
|
23
|
+
"./providers/*": "./.medusa/server/src/providers/*/index.js",
|
|
24
|
+
"./storefront": {
|
|
25
|
+
"types": "./.medusa/server/src/storefront.d.ts",
|
|
26
|
+
"import": "./.medusa/server/src/storefront.js",
|
|
27
|
+
"require": "./.medusa/server/src/storefront.js",
|
|
28
|
+
"default": "./.medusa/server/src/storefront.js"
|
|
29
|
+
},
|
|
30
|
+
"./*": "./.medusa/server/src/*.js",
|
|
31
|
+
"./admin": {
|
|
32
|
+
"import": "./.medusa/server/src/admin/index.mjs",
|
|
33
|
+
"require": "./.medusa/server/src/admin/index.js",
|
|
34
|
+
"default": "./.medusa/server/src/admin/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"medusa",
|
|
39
|
+
"plugin",
|
|
40
|
+
"medusa-plugin",
|
|
41
|
+
"medusa-plugin-other",
|
|
42
|
+
"medusa-v2",
|
|
43
|
+
"compliance",
|
|
44
|
+
"checkout",
|
|
45
|
+
"firearms",
|
|
46
|
+
"age-verification",
|
|
47
|
+
"ordinant"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "medusa plugin:build",
|
|
51
|
+
"postbuild": "tsc src/storefront.ts --declaration --emitDeclarationOnly --outDir .medusa/server/src --skipLibCheck --target ES2020 --module ESNext --moduleResolution node",
|
|
52
|
+
"dev": "medusa plugin:develop",
|
|
53
|
+
"lint": "medusa lint",
|
|
54
|
+
"prepublishOnly": "npm run build"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@medusajs/admin-sdk": "2.17.2",
|
|
58
|
+
"@medusajs/cli": "2.17.2",
|
|
59
|
+
"@medusajs/eslint-plugin": "2.17.2",
|
|
60
|
+
"@medusajs/framework": "2.17.2",
|
|
61
|
+
"@medusajs/icons": "2.17.2",
|
|
62
|
+
"@medusajs/medusa": "2.17.2",
|
|
63
|
+
"@medusajs/test-utils": "2.17.2",
|
|
64
|
+
"@medusajs/ui": "4.1.19",
|
|
65
|
+
"@swc/core": "^1.7.28",
|
|
66
|
+
"@types/node": "^20.0.0",
|
|
67
|
+
"@types/react": "^18.3.2",
|
|
68
|
+
"@types/react-dom": "^18.2.25",
|
|
69
|
+
"eslint": "^9.0.0",
|
|
70
|
+
"jiti": "^2.0.0",
|
|
71
|
+
"prop-types": "^15.8.1",
|
|
72
|
+
"react": "^18.2.0",
|
|
73
|
+
"react-dom": "^18.2.0",
|
|
74
|
+
"ts-node": "^10.9.2",
|
|
75
|
+
"typescript": "^5.6.2",
|
|
76
|
+
"vite": "^5.2.11",
|
|
77
|
+
"yalc": "^1.0.0-pre.53"
|
|
78
|
+
},
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"@medusajs/admin-sdk": "2.17.2",
|
|
81
|
+
"@medusajs/cli": "2.17.2",
|
|
82
|
+
"@medusajs/framework": "2.17.2",
|
|
83
|
+
"@medusajs/icons": "2.17.2",
|
|
84
|
+
"@medusajs/medusa": "2.17.2",
|
|
85
|
+
"@medusajs/test-utils": "2.17.2",
|
|
86
|
+
"@medusajs/ui": "4.1.19"
|
|
87
|
+
},
|
|
88
|
+
"engines": {
|
|
89
|
+
"node": ">=20"
|
|
90
|
+
},
|
|
91
|
+
"packageManager": "npm@11.6.1"
|
|
92
|
+
}
|