@jytextiles/medusa-plugin-faire-store-sync 0.2.4 → 0.2.6
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/__tests__/faire-client.spec.js +27 -7
- package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +61 -1
- package/.medusa/server/src/admin/index.js +182 -132
- package/.medusa/server/src/admin/index.mjs +184 -134
- package/.medusa/server/src/lib/faire-client.js +100 -18
- package/.medusa/server/src/lib/types.js +6 -2
- package/.medusa/server/src/modules/faire-sync/migrations/Migration20260709161201.js +14 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +5 -1
- package/.medusa/server/src/modules/faire-sync/service.js +33 -14
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +81 -28
- package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +2 -2
- package/.medusa/server/src/workflows/sync-product-to-faire.js +109 -37
- package/package.json +1 -1
- package/src/__tests__/faire-client.spec.ts +28 -6
- package/src/__tests__/ingest-faire-order-support.spec.ts +63 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +56 -61
- package/src/admin/routes/settings/faire/page.tsx +78 -8
- package/src/lib/faire-client.ts +112 -16
- package/src/lib/types.ts +57 -18
- package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +19 -0
- package/src/modules/faire-sync/migrations/Migration20260709161201.ts +13 -0
- package/src/modules/faire-sync/models/faire-sync-account.ts +4 -0
- package/src/modules/faire-sync/service.ts +42 -16
- package/src/workflows/ingest-faire-order-support.ts +96 -29
- package/src/workflows/ingest-faire-orders-bulk.ts +1 -1
- package/src/workflows/sync-product-to-faire.ts +143 -41
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Button,
|
|
3
|
-
Container,
|
|
4
3
|
DropdownMenu,
|
|
5
4
|
Heading,
|
|
6
5
|
IconButton,
|
|
@@ -14,8 +13,11 @@ import { EllipsisHorizontal, ArrowPath, ArrowUpRightOnBox } from "@medusajs/icon
|
|
|
14
13
|
import { useEffect, useState } from "react"
|
|
15
14
|
import { useNavigate, useParams } from "react-router-dom"
|
|
16
15
|
import { faireApi } from "../../../../lib/api"
|
|
16
|
+
import { RouteFocusModal } from "../../../../components/route-focus-modal"
|
|
17
17
|
import type { FaireSyncRecord } from "../hooks/use-faire-sync-columns"
|
|
18
18
|
|
|
19
|
+
const PARENT = "/settings/faire"
|
|
20
|
+
|
|
19
21
|
const STATUS_COLORS: Record<string, "green" | "red" | "orange" | "grey"> = {
|
|
20
22
|
success: "green",
|
|
21
23
|
failed: "red",
|
|
@@ -59,47 +61,23 @@ const FaireSyncDetailPage = () => {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<Container className="p-6">
|
|
65
|
-
<Skeleton className="h-7 w-12 rounded-md" />
|
|
66
|
-
</Container>
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!record) {
|
|
71
|
-
return (
|
|
72
|
-
<Container className="p-6 flex flex-col gap-4">
|
|
73
|
-
<Heading level="h1">Sync record not found</Heading>
|
|
74
|
-
<Button size="small" variant="secondary" onClick={() => navigate("/settings/faire")}>
|
|
75
|
-
Back to Faire settings
|
|
76
|
-
</Button>
|
|
77
|
-
</Container>
|
|
78
|
-
)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const warnings: string[] = record.error_message
|
|
64
|
+
const warnings: string[] = record?.error_message
|
|
82
65
|
? record.error_message.split(" | ")
|
|
83
66
|
: []
|
|
84
67
|
|
|
85
68
|
return (
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
)}
|
|
92
|
-
|
|
93
|
-
<Container className="divide-y p-0">
|
|
94
|
-
<div className="flex items-center justify-between px-6 py-4">
|
|
95
|
-
<div className="flex flex-col gap-1">
|
|
96
|
-
<Heading level="h1">Sync record</Heading>
|
|
97
|
-
<Text className="text-ui-fg-subtle font-mono text-xs">{record.id}</Text>
|
|
98
|
-
</div>
|
|
99
|
-
<div className="flex items-center gap-3">
|
|
69
|
+
<RouteFocusModal prev={PARENT}>
|
|
70
|
+
<RouteFocusModal.Header>
|
|
71
|
+
<div className="flex items-center gap-3">
|
|
72
|
+
<Heading level="h1">Sync record</Heading>
|
|
73
|
+
{record && (
|
|
100
74
|
<StatusBadge color={STATUS_COLORS[record.status] || "grey"}>
|
|
101
75
|
{record.status}
|
|
102
76
|
</StatusBadge>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
{record && (
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
103
81
|
<DropdownMenu>
|
|
104
82
|
<DropdownMenu.Trigger asChild>
|
|
105
83
|
<IconButton size="small" variant="transparent" disabled={retrying}>
|
|
@@ -127,38 +105,55 @@ const FaireSyncDetailPage = () => {
|
|
|
127
105
|
</DropdownMenu.Content>
|
|
128
106
|
</DropdownMenu>
|
|
129
107
|
</div>
|
|
130
|
-
|
|
108
|
+
)}
|
|
109
|
+
</RouteFocusModal.Header>
|
|
131
110
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
<Detail label="Action" value={record.action} />
|
|
135
|
-
<Detail label="Faire product token" value={record.product_token || "—"} mono />
|
|
136
|
-
<Detail label="State" value={record.product_state || "—"} />
|
|
137
|
-
<Detail label="Published" value={record.published ? "Yes" : "No"} />
|
|
138
|
-
<Detail
|
|
139
|
-
label="Synced at"
|
|
140
|
-
value={record.synced_at ? new Date(record.synced_at).toLocaleString() : "—"}
|
|
141
|
-
/>
|
|
142
|
-
</div>
|
|
143
|
-
</Container>
|
|
111
|
+
<RouteFocusModal.Body className="flex flex-col gap-4 overflow-y-auto p-6">
|
|
112
|
+
{error && <Text className="text-ui-tag-red-text">{error}</Text>}
|
|
144
113
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
</div>
|
|
150
|
-
<div className="px-6 py-4 flex flex-col gap-2">
|
|
151
|
-
{warnings.map((w, i) => (
|
|
152
|
-
<Text key={i} className="text-ui-tag-red-text text-sm">
|
|
153
|
-
{w}
|
|
154
|
-
</Text>
|
|
114
|
+
{loading ? (
|
|
115
|
+
<div className="flex flex-col gap-3">
|
|
116
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
117
|
+
<Skeleton key={i} className="h-10 w-full" />
|
|
155
118
|
))}
|
|
156
119
|
</div>
|
|
157
|
-
|
|
158
|
-
|
|
120
|
+
) : !record ? (
|
|
121
|
+
<div className="flex flex-col items-start gap-4">
|
|
122
|
+
<Heading level="h2">Sync record not found</Heading>
|
|
123
|
+
<Button size="small" variant="secondary" onClick={() => navigate(PARENT)}>
|
|
124
|
+
Back to Faire settings
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
) : (
|
|
128
|
+
<>
|
|
129
|
+
<div className="border-ui-border-base grid grid-cols-2 gap-4 rounded-lg border p-4">
|
|
130
|
+
<Detail label="Record id" value={record.id} mono />
|
|
131
|
+
<Detail label="Product" value={record.product_id} mono />
|
|
132
|
+
<Detail label="Action" value={record.action} />
|
|
133
|
+
<Detail label="Faire product token" value={record.product_token || "—"} mono />
|
|
134
|
+
<Detail label="State" value={record.product_state || "—"} />
|
|
135
|
+
<Detail label="Published" value={record.published ? "Yes" : "No"} />
|
|
136
|
+
<Detail
|
|
137
|
+
label="Synced at"
|
|
138
|
+
value={record.synced_at ? new Date(record.synced_at).toLocaleString() : "—"}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
159
141
|
|
|
142
|
+
{warnings.length > 0 && (
|
|
143
|
+
<div className="border-ui-border-base flex flex-col gap-2 rounded-lg border p-4">
|
|
144
|
+
<Heading level="h2">Issues</Heading>
|
|
145
|
+
{warnings.map((w, i) => (
|
|
146
|
+
<Text key={i} className="text-ui-tag-red-text text-sm">
|
|
147
|
+
{w}
|
|
148
|
+
</Text>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</RouteFocusModal.Body>
|
|
160
155
|
<Toaster />
|
|
161
|
-
</
|
|
156
|
+
</RouteFocusModal>
|
|
162
157
|
)
|
|
163
158
|
}
|
|
164
159
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
|
2
|
-
import { BuildingStorefront } from "@medusajs/icons"
|
|
2
|
+
import { BuildingStorefront, ChevronDownMini } from "@medusajs/icons"
|
|
3
3
|
import {
|
|
4
4
|
Alert,
|
|
5
5
|
Button,
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
DataTable,
|
|
8
8
|
DataTableFilteringState,
|
|
9
9
|
DataTablePaginationState,
|
|
10
|
+
Drawer,
|
|
11
|
+
DropdownMenu,
|
|
10
12
|
Heading,
|
|
13
|
+
Input,
|
|
11
14
|
Label,
|
|
12
15
|
Skeleton,
|
|
13
16
|
StatusBadge,
|
|
@@ -53,6 +56,8 @@ const FaireSettingsPage = () => {
|
|
|
53
56
|
pageSize: PAGE_SIZE,
|
|
54
57
|
})
|
|
55
58
|
const [filtering, setFiltering] = useState<DataTableFilteringState>({})
|
|
59
|
+
const [apiKeyOpen, setApiKeyOpen] = useState(false)
|
|
60
|
+
const [apiKey, setApiKey] = useState("")
|
|
56
61
|
|
|
57
62
|
// ── Queries ───────────────────────────────────────────────────────────────
|
|
58
63
|
const statusQuery = useQuery({
|
|
@@ -91,6 +96,20 @@ const FaireSettingsPage = () => {
|
|
|
91
96
|
}),
|
|
92
97
|
})
|
|
93
98
|
|
|
99
|
+
const apiKeyMutation = useMutation({
|
|
100
|
+
mutationFn: () => faireApi.connectApiKey(apiKey.trim()),
|
|
101
|
+
onSuccess: () => {
|
|
102
|
+
queryClient.invalidateQueries({ queryKey: STATUS_KEY })
|
|
103
|
+
setApiKeyOpen(false)
|
|
104
|
+
setApiKey("")
|
|
105
|
+
toast.success("Connected to Faire with API key")
|
|
106
|
+
},
|
|
107
|
+
onError: (err: any) =>
|
|
108
|
+
toast.error("Failed to connect with API key", {
|
|
109
|
+
description: err.message,
|
|
110
|
+
}),
|
|
111
|
+
})
|
|
112
|
+
|
|
94
113
|
const disconnectMutation = useMutation({
|
|
95
114
|
mutationFn: () => faireApi.disconnect(),
|
|
96
115
|
onSuccess: () => {
|
|
@@ -211,13 +230,22 @@ const FaireSettingsPage = () => {
|
|
|
211
230
|
</Button>
|
|
212
231
|
</div>
|
|
213
232
|
) : (
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
233
|
+
<DropdownMenu>
|
|
234
|
+
<DropdownMenu.Trigger asChild>
|
|
235
|
+
<Button size="small" isLoading={connectMutation.isPending}>
|
|
236
|
+
Connect Faire
|
|
237
|
+
<ChevronDownMini />
|
|
238
|
+
</Button>
|
|
239
|
+
</DropdownMenu.Trigger>
|
|
240
|
+
<DropdownMenu.Content>
|
|
241
|
+
<DropdownMenu.Item onClick={() => connectMutation.mutate()}>
|
|
242
|
+
Connect with OAuth
|
|
243
|
+
</DropdownMenu.Item>
|
|
244
|
+
<DropdownMenu.Item onClick={() => setApiKeyOpen(true)}>
|
|
245
|
+
Connect with API key (private)
|
|
246
|
+
</DropdownMenu.Item>
|
|
247
|
+
</DropdownMenu.Content>
|
|
248
|
+
</DropdownMenu>
|
|
221
249
|
)}
|
|
222
250
|
</div>
|
|
223
251
|
{connected && status?.account && (
|
|
@@ -264,6 +292,48 @@ const FaireSettingsPage = () => {
|
|
|
264
292
|
</DataTable>
|
|
265
293
|
</Container>
|
|
266
294
|
|
|
295
|
+
{/* API-key connect (private integrations) */}
|
|
296
|
+
<Drawer open={apiKeyOpen} onOpenChange={setApiKeyOpen}>
|
|
297
|
+
<Drawer.Content>
|
|
298
|
+
<Drawer.Header>
|
|
299
|
+
<Drawer.Title>Connect with API key</Drawer.Title>
|
|
300
|
+
</Drawer.Header>
|
|
301
|
+
<Drawer.Body className="flex flex-col gap-y-3">
|
|
302
|
+
<Text className="text-ui-fg-subtle" size="small">
|
|
303
|
+
For private / unpublished Faire integrations, paste the API key
|
|
304
|
+
your Faire brand issued for this app. This connects without OAuth.
|
|
305
|
+
</Text>
|
|
306
|
+
<div className="flex flex-col gap-y-1">
|
|
307
|
+
<Label size="small">Faire API key</Label>
|
|
308
|
+
<Input
|
|
309
|
+
type="password"
|
|
310
|
+
placeholder="Paste the Faire API key…"
|
|
311
|
+
value={apiKey}
|
|
312
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
313
|
+
autoComplete="off"
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
</Drawer.Body>
|
|
317
|
+
<Drawer.Footer>
|
|
318
|
+
<Button
|
|
319
|
+
variant="secondary"
|
|
320
|
+
size="small"
|
|
321
|
+
onClick={() => setApiKeyOpen(false)}
|
|
322
|
+
>
|
|
323
|
+
Cancel
|
|
324
|
+
</Button>
|
|
325
|
+
<Button
|
|
326
|
+
size="small"
|
|
327
|
+
onClick={() => apiKeyMutation.mutate()}
|
|
328
|
+
isLoading={apiKeyMutation.isPending}
|
|
329
|
+
disabled={!apiKey.trim()}
|
|
330
|
+
>
|
|
331
|
+
Connect
|
|
332
|
+
</Button>
|
|
333
|
+
</Drawer.Footer>
|
|
334
|
+
</Drawer.Content>
|
|
335
|
+
</Drawer>
|
|
336
|
+
|
|
267
337
|
<Toaster />
|
|
268
338
|
</div>
|
|
269
339
|
)
|
package/src/lib/faire-client.ts
CHANGED
|
@@ -81,12 +81,25 @@ export class FaireClient {
|
|
|
81
81
|
|
|
82
82
|
// ── OAuth ───────────────────────────────────────────────────────────────
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Normalise the configured scope into a list of individual scope tokens.
|
|
86
|
+
*
|
|
87
|
+
* `FAIRE_SCOPE` may be authored comma- OR space-separated (e.g.
|
|
88
|
+
* "READ_PRODUCTS WRITE_PRODUCTS" or "READ_PRODUCTS,WRITE_PRODUCTS"); split on
|
|
89
|
+
* either so each Faire scope enum is a distinct element. Passing the whole
|
|
90
|
+
* joined string as a single value makes Faire's authorize reject it with
|
|
91
|
+
* `scope.contains(null)` (it comma-splits, sees one unknown enum → null).
|
|
92
|
+
*/
|
|
93
|
+
private scopeList(): string[] {
|
|
94
|
+
return this.scope ? this.scope.split(/[\s,]+/).filter(Boolean) : []
|
|
95
|
+
}
|
|
96
|
+
|
|
84
97
|
/**
|
|
85
98
|
* Build the Faire OAuth authorize URL.
|
|
86
99
|
*
|
|
87
100
|
* Faire's authorize endpoint takes `applicationId` (NOT client_id),
|
|
88
|
-
* `redirectUrl` (NOT redirect_uri) and a
|
|
89
|
-
* round-tripped for CSRF protection.
|
|
101
|
+
* `redirectUrl` (NOT redirect_uri) and a COMMA-joined `scope` (Faire splits
|
|
102
|
+
* the scope param on commas). `state` is round-tripped for CSRF protection.
|
|
90
103
|
*/
|
|
91
104
|
getAuthorizationUrl(state: string): string {
|
|
92
105
|
const params = new URLSearchParams({
|
|
@@ -94,7 +107,8 @@ export class FaireClient {
|
|
|
94
107
|
redirectUrl: this.redirectUri,
|
|
95
108
|
state,
|
|
96
109
|
})
|
|
97
|
-
|
|
110
|
+
const scopes = this.scopeList()
|
|
111
|
+
if (scopes.length) params.set("scope", scopes.join(","))
|
|
98
112
|
return `${this.authUrl}?${params.toString()}`
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -113,7 +127,7 @@ export class FaireClient {
|
|
|
113
127
|
authorization_code: code,
|
|
114
128
|
grant_type: "AUTHORIZATION_CODE",
|
|
115
129
|
redirect_url: this.redirectUri,
|
|
116
|
-
scope: this.
|
|
130
|
+
scope: this.scopeList(),
|
|
117
131
|
}
|
|
118
132
|
const data = await this.requestJson<any>(this.tokenUrl, {
|
|
119
133
|
method: "POST",
|
|
@@ -275,10 +289,15 @@ export class FaireClient {
|
|
|
275
289
|
const results = (data.products ?? data.results ?? []).map((p: any) =>
|
|
276
290
|
this.mapProduct(p)
|
|
277
291
|
)
|
|
292
|
+
// Numeric 1-indexed `page`, no `next_page` cursor (see listOrders).
|
|
293
|
+
const limit = opts.limit ?? 100
|
|
294
|
+
const currentPage = Number(opts.page ?? data.page ?? 1)
|
|
295
|
+
const next_page =
|
|
296
|
+
results.length >= limit ? String(currentPage + 1) : undefined
|
|
278
297
|
return {
|
|
279
298
|
count: data.count ?? results.length,
|
|
280
299
|
results,
|
|
281
|
-
next_page
|
|
300
|
+
next_page,
|
|
282
301
|
}
|
|
283
302
|
}
|
|
284
303
|
|
|
@@ -287,10 +306,11 @@ export class FaireClient {
|
|
|
287
306
|
/**
|
|
288
307
|
* Push inventory overrides to Faire by SKU.
|
|
289
308
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
309
|
+
* Inventory push is write-only: `PATCH /product-inventory/by-skus` with an
|
|
310
|
+
* array under `inventories`, each row `{ sku, on_hand_quantity }` (verified
|
|
311
|
+
* live 2026-07-09 — the writable field is `on_hand_quantity`, NOT
|
|
312
|
+
* `current_count`/`available_quantity`). A `by-product-variant-ids` variant
|
|
313
|
+
* exists for id-keyed overrides.
|
|
294
314
|
*/
|
|
295
315
|
async updateInventory(
|
|
296
316
|
accessToken: string,
|
|
@@ -332,10 +352,17 @@ export class FaireClient {
|
|
|
332
352
|
const results = (data.orders ?? data.results ?? []).map((o: any) =>
|
|
333
353
|
this.mapOrder(o)
|
|
334
354
|
)
|
|
355
|
+
// Faire v2 paginates by a numeric 1-indexed `page` and returns NO
|
|
356
|
+
// `next_page` cursor (verified live 2026-07-09). A full page (rows ===
|
|
357
|
+
// limit) implies there may be another; a short/empty page is the last.
|
|
358
|
+
const limit = opts.limit ?? 100
|
|
359
|
+
const currentPage = Number(opts.page ?? data.page ?? 1)
|
|
360
|
+
const next_page =
|
|
361
|
+
results.length >= limit ? String(currentPage + 1) : undefined
|
|
335
362
|
return {
|
|
336
363
|
count: data.count ?? results.length,
|
|
337
364
|
results,
|
|
338
|
-
next_page
|
|
365
|
+
next_page,
|
|
339
366
|
}
|
|
340
367
|
}
|
|
341
368
|
|
|
@@ -421,28 +448,97 @@ export class FaireClient {
|
|
|
421
448
|
}
|
|
422
449
|
|
|
423
450
|
private mapProduct(data: any): ProductResponse {
|
|
451
|
+
// v2 returns `id` (p_…) + `lifecycle_state` (DRAFT|PUBLISHED). Normalise
|
|
452
|
+
// lifecycle_state → the internal `state` used downstream for publish gating.
|
|
453
|
+
const lifecycle = data.lifecycle_state ?? data.state
|
|
454
|
+
const state =
|
|
455
|
+
lifecycle === "PUBLISHED" || data.state === "active"
|
|
456
|
+
? "active"
|
|
457
|
+
: lifecycle === "DRAFT" || data.state === "draft"
|
|
458
|
+
? "draft"
|
|
459
|
+
: data.state
|
|
424
460
|
return {
|
|
425
461
|
product_token: String(data.product_token ?? data.id ?? data.token),
|
|
426
462
|
brand_id: data.brand_id != null ? String(data.brand_id) : undefined,
|
|
427
463
|
name: data.name,
|
|
428
464
|
description: data.description,
|
|
429
|
-
state
|
|
465
|
+
state,
|
|
430
466
|
url: data.url,
|
|
431
|
-
wholesale_price_cents: data.wholesale_price_cents,
|
|
432
|
-
retail_price_cents: data.retail_price_cents,
|
|
433
467
|
variants: data.variants,
|
|
434
468
|
images: data.images,
|
|
435
469
|
raw: data,
|
|
436
470
|
}
|
|
437
471
|
}
|
|
438
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Resolve a Faire taxonomy_type id (`tt_…`) from a category name (or pass a
|
|
475
|
+
* `tt_…` id straight through). Faire create REQUIRES `taxonomy_type.id`;
|
|
476
|
+
* `GET /products/types` returns all ~3k `{ id, name }` rows under the
|
|
477
|
+
* `taxonomy_types` key in a single unpaginated response (verified live
|
|
478
|
+
* 2026-07-09; the `limit` param is ignored). Cached per client.
|
|
479
|
+
*/
|
|
480
|
+
private taxonomyTypesCache: Array<{ id: string; name: string }> | null = null
|
|
481
|
+
async resolveTaxonomyTypeId(
|
|
482
|
+
accessToken: string,
|
|
483
|
+
nameOrId: string
|
|
484
|
+
): Promise<string | null> {
|
|
485
|
+
if (!nameOrId) return null
|
|
486
|
+
if (/^tt_/.test(nameOrId)) return nameOrId
|
|
487
|
+
if (!this.taxonomyTypesCache) {
|
|
488
|
+
const data = await this.requestJson<any>(
|
|
489
|
+
`${this.apiBase}/products/types`,
|
|
490
|
+
{ method: "GET", accessToken }
|
|
491
|
+
)
|
|
492
|
+
this.taxonomyTypesCache = (
|
|
493
|
+
data.taxonomy_types ??
|
|
494
|
+
data.product_types ??
|
|
495
|
+
data.types ??
|
|
496
|
+
data.results ??
|
|
497
|
+
[]
|
|
498
|
+
).map((t: any) => ({ id: String(t.id), name: String(t.name ?? "") }))
|
|
499
|
+
}
|
|
500
|
+
const needle = nameOrId.trim().toLowerCase()
|
|
501
|
+
const list = this.taxonomyTypesCache ?? []
|
|
502
|
+
const exact = list.find((t) => t.name.toLowerCase() === needle)
|
|
503
|
+
if (exact) return exact.id
|
|
504
|
+
// Prefer a whole-word match (needle as its own token) over a loose
|
|
505
|
+
// substring — otherwise "Dress" would resolve to "Address Book" (contains
|
|
506
|
+
// "ad-dress"). Fall back to substring only if no word-boundary hit.
|
|
507
|
+
const wordRe = new RegExp(`\\b${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)
|
|
508
|
+
const word = list.find((t) => wordRe.test(t.name.toLowerCase()))
|
|
509
|
+
if (word) return word.id
|
|
510
|
+
const partial = list.find((t) => t.name.toLowerCase().includes(needle))
|
|
511
|
+
return partial?.id ?? null
|
|
512
|
+
}
|
|
513
|
+
|
|
439
514
|
private mapOrder(data: any): FaireOrder {
|
|
515
|
+
// ExternalOrderV2 has no top-level currency/total/buyer — money is per-item
|
|
516
|
+
// ExternalMoneyV2 (`price.amount_minor`+`currency`) and the buyer is
|
|
517
|
+
// `customer { first_name, last_name }`. Derive them; keep `raw` for the
|
|
518
|
+
// ingest mapper which reads the full shape. Verified live 2026-07-09.
|
|
519
|
+
const items: any[] = Array.isArray(data.items) ? data.items : []
|
|
520
|
+
const firstCur = items
|
|
521
|
+
.map((it) => it?.price?.currency)
|
|
522
|
+
.find((c) => typeof c === "string" && c)
|
|
523
|
+
const totalCents = items.reduce(
|
|
524
|
+
(sum, it) =>
|
|
525
|
+
sum +
|
|
526
|
+
Number(it?.price?.amount_minor ?? it?.price_cents ?? 0) *
|
|
527
|
+
Math.max(1, Number(it?.quantity) || 1),
|
|
528
|
+
0
|
|
529
|
+
)
|
|
530
|
+
const buyer =
|
|
531
|
+
[data.customer?.first_name, data.customer?.last_name]
|
|
532
|
+
.filter(Boolean)
|
|
533
|
+
.join(" ") ||
|
|
534
|
+
data.address?.name ||
|
|
535
|
+
undefined
|
|
440
536
|
return {
|
|
441
537
|
order_token: String(data.order_token ?? data.id ?? data.token),
|
|
442
538
|
state: data.state ?? data.status,
|
|
443
|
-
currency: data.currency ?? data.currency_code,
|
|
444
|
-
total_cents: data.total_cents ?? data.grand_total_cents,
|
|
445
|
-
buyer_name:
|
|
539
|
+
currency: firstCur ?? data.currency ?? data.currency_code,
|
|
540
|
+
total_cents: items.length ? totalCents : data.total_cents ?? data.grand_total_cents,
|
|
541
|
+
buyer_name: buyer,
|
|
446
542
|
raw: data,
|
|
447
543
|
}
|
|
448
544
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -28,7 +28,11 @@ export interface FairePluginOptions {
|
|
|
28
28
|
tokenUrl?: string
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// MUST be the canonical www host. Bare `faire.com` 301-redirects to www, and a
|
|
32
|
+
// 301 on a POST/PUT strips the request body (fetch/curl replay it without the
|
|
33
|
+
// payload) — so writes (createProduct/updateProduct) silently no-op'd, coming
|
|
34
|
+
// back as if they were `GET /products` (a product LIST). Verified live 2026-07-09.
|
|
35
|
+
export const DEFAULT_API_BASE = "https://www.faire.com/external-api/v2"
|
|
32
36
|
export const DEFAULT_AUTH_URL = "https://faire.com/oauth2/authorize"
|
|
33
37
|
export const DEFAULT_TOKEN_URL = "https://www.faire.com/api/external-api-oauth2/token"
|
|
34
38
|
// Faire OAuth scopes are coarse tokens like READ_ORDERS, WRITE_PRODUCTS. The
|
|
@@ -69,27 +73,59 @@ export interface ProductImage {
|
|
|
69
73
|
url: string
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Faire External API v2 create/update product contract (verified live
|
|
78
|
+
* 2026-07-09 against `POST https://www.faire.com/external-api/v2/products`).
|
|
79
|
+
*
|
|
80
|
+
* Money is `amount_minor` (integer minor units = cents) + `currency`. Each
|
|
81
|
+
* variant price is scoped to a geo (`country` ISO-2 OR a `country_group` enum
|
|
82
|
+
* like EUROPEAN_UNION). `taxonomy_type.id` (a `tt_…` id from GET /products/types)
|
|
83
|
+
* and per-entity `idempotence_token`s are REQUIRED — omitting either 400s.
|
|
84
|
+
*/
|
|
85
|
+
export type FaireLifecycleState = "DRAFT" | "PUBLISHED"
|
|
86
|
+
|
|
87
|
+
export interface FaireGeoConstraint {
|
|
88
|
+
country?: string
|
|
89
|
+
country_group?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FaireMoneyMinor {
|
|
93
|
+
amount_minor: number
|
|
94
|
+
currency: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FaireVariantPrice {
|
|
98
|
+
geo_constraint: FaireGeoConstraint
|
|
99
|
+
wholesale_price: FaireMoneyMinor
|
|
100
|
+
retail_price: FaireMoneyMinor
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FaireVariantOption {
|
|
104
|
+
name: string
|
|
105
|
+
value: string
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
export interface ProductVariantInput {
|
|
73
109
|
sku: string
|
|
74
|
-
name
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
name: string
|
|
111
|
+
idempotence_token: string
|
|
112
|
+
options?: FaireVariantOption[]
|
|
113
|
+
available_quantity?: number
|
|
114
|
+
prices?: FaireVariantPrice[]
|
|
78
115
|
images?: ProductImage[]
|
|
79
116
|
}
|
|
80
117
|
|
|
81
118
|
export interface CreateProductInput {
|
|
82
|
-
brand_id: string
|
|
83
119
|
name: string
|
|
120
|
+
idempotence_token: string
|
|
121
|
+
lifecycle_state: FaireLifecycleState
|
|
122
|
+
taxonomy_type: { id: string }
|
|
84
123
|
description?: string
|
|
85
|
-
wholesale_price_cents?: number
|
|
86
|
-
retail_price_cents?: number
|
|
87
|
-
images?: ProductImage[]
|
|
88
|
-
variants?: ProductVariantInput[]
|
|
89
|
-
tags?: string[]
|
|
90
|
-
shipping?: Record<string, any>
|
|
91
|
-
metadata?: Record<string, any>
|
|
92
124
|
short_description?: string
|
|
125
|
+
variants: ProductVariantInput[]
|
|
126
|
+
images?: ProductImage[]
|
|
127
|
+
unit_multiplier?: number
|
|
128
|
+
minimum_order_quantity?: number
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
export type UpdateProductInput = Partial<CreateProductInput>
|
|
@@ -101,8 +137,6 @@ export interface ProductResponse {
|
|
|
101
137
|
description?: string
|
|
102
138
|
state?: ProductState
|
|
103
139
|
url?: string
|
|
104
|
-
wholesale_price_cents?: number
|
|
105
|
-
retail_price_cents?: number
|
|
106
140
|
variants?: any[]
|
|
107
141
|
images?: ProductImage[]
|
|
108
142
|
raw: Record<string, any>
|
|
@@ -116,12 +150,17 @@ export interface InventoryLevel {
|
|
|
116
150
|
}
|
|
117
151
|
|
|
118
152
|
/**
|
|
119
|
-
* Payload for `PATCH /product-inventory/by-skus`. Faire ingests an array
|
|
120
|
-
*
|
|
153
|
+
* Payload row for `PATCH /product-inventory/by-skus`. Faire ingests an array
|
|
154
|
+
* under `inventories`; each row carries the SKU and the new on-hand count.
|
|
155
|
+
* The writable quantity field is `on_hand_quantity` (integer) — verified live
|
|
156
|
+
* 2026-07-09 against the docs (there is NO `current_count`/`available_quantity`
|
|
157
|
+
* on this endpoint; those belong to other inventory endpoints). `product_variant_id`
|
|
158
|
+
* is optional — Faire resolves the row by SKU when it's omitted.
|
|
121
159
|
*/
|
|
122
160
|
export interface InventoryOverrideBySku {
|
|
123
161
|
sku: string
|
|
124
|
-
|
|
162
|
+
on_hand_quantity: number
|
|
163
|
+
product_variant_id?: string
|
|
125
164
|
}
|
|
126
165
|
|
|
127
166
|
export interface FaireOrder {
|
|
@@ -301,6 +301,25 @@
|
|
|
301
301
|
"enumItems": [],
|
|
302
302
|
"mappedType": "text"
|
|
303
303
|
},
|
|
304
|
+
"auth_mode": {
|
|
305
|
+
"name": "auth_mode",
|
|
306
|
+
"type": "text",
|
|
307
|
+
"unsigned": false,
|
|
308
|
+
"autoincrement": false,
|
|
309
|
+
"primary": false,
|
|
310
|
+
"nullable": false,
|
|
311
|
+
"unique": false,
|
|
312
|
+
"length": null,
|
|
313
|
+
"precision": null,
|
|
314
|
+
"scale": null,
|
|
315
|
+
"default": "'oauth'",
|
|
316
|
+
"comment": null,
|
|
317
|
+
"enumItems": [
|
|
318
|
+
"oauth",
|
|
319
|
+
"apiKey"
|
|
320
|
+
],
|
|
321
|
+
"mappedType": "enum"
|
|
322
|
+
},
|
|
304
323
|
"access_token": {
|
|
305
324
|
"name": "access_token",
|
|
306
325
|
"type": "text",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
|
2
|
+
|
|
3
|
+
export class Migration20260709161201 extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`alter table if exists "faire_sync_account" add column if not exists "auth_mode" text check ("auth_mode" in ('oauth', 'apiKey')) not null default 'oauth';`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
override async down(): Promise<void> {
|
|
10
|
+
this.addSql(`alter table if exists "faire_sync_account" drop column if exists "auth_mode";`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|
|
@@ -6,6 +6,10 @@ const FaireSyncAccount = model.define("faire_sync_account", {
|
|
|
6
6
|
brand_name: model.text(),
|
|
7
7
|
currency: model.text().nullable(),
|
|
8
8
|
country: model.text().nullable(),
|
|
9
|
+
// How this account authenticates to Faire: OAuth (published apps) or a
|
|
10
|
+
// brand-issued API key (private/single-merchant integrations). Drives which
|
|
11
|
+
// auth header the client sends, so both connection types can coexist.
|
|
12
|
+
auth_mode: model.enum(["oauth", "apiKey"]).default("oauth"),
|
|
9
13
|
access_token: model.text(),
|
|
10
14
|
refresh_token: model.text().nullable(),
|
|
11
15
|
token_expires_at: model.dateTime().nullable(),
|