@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.
Files changed (26) hide show
  1. package/.medusa/server/src/__tests__/faire-client.spec.js +27 -7
  2. package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +61 -1
  3. package/.medusa/server/src/admin/index.js +182 -132
  4. package/.medusa/server/src/admin/index.mjs +184 -134
  5. package/.medusa/server/src/lib/faire-client.js +100 -18
  6. package/.medusa/server/src/lib/types.js +6 -2
  7. package/.medusa/server/src/modules/faire-sync/migrations/Migration20260709161201.js +14 -0
  8. package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +5 -1
  9. package/.medusa/server/src/modules/faire-sync/service.js +33 -14
  10. package/.medusa/server/src/workflows/ingest-faire-order-support.js +81 -28
  11. package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +2 -2
  12. package/.medusa/server/src/workflows/sync-product-to-faire.js +109 -37
  13. package/package.json +1 -1
  14. package/src/__tests__/faire-client.spec.ts +28 -6
  15. package/src/__tests__/ingest-faire-order-support.spec.ts +63 -0
  16. package/src/admin/routes/settings/faire/[id]/page.tsx +56 -61
  17. package/src/admin/routes/settings/faire/page.tsx +78 -8
  18. package/src/lib/faire-client.ts +112 -16
  19. package/src/lib/types.ts +57 -18
  20. package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +19 -0
  21. package/src/modules/faire-sync/migrations/Migration20260709161201.ts +13 -0
  22. package/src/modules/faire-sync/models/faire-sync-account.ts +4 -0
  23. package/src/modules/faire-sync/service.ts +42 -16
  24. package/src/workflows/ingest-faire-order-support.ts +96 -29
  25. package/src/workflows/ingest-faire-orders-bulk.ts +1 -1
  26. 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
- if (loading) {
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
- <div className="flex flex-col gap-4">
87
- {error && (
88
- <Container className="p-4">
89
- <Text className="text-ui-tag-red-text">{error}</Text>
90
- </Container>
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
- </div>
108
+ )}
109
+ </RouteFocusModal.Header>
131
110
 
132
- <div className="px-6 py-4 grid grid-cols-2 gap-4">
133
- <Detail label="Product" value={record.product_id} mono />
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
- {warnings.length > 0 && (
146
- <Container className="divide-y p-0">
147
- <div className="px-6 py-4">
148
- <Heading level="h2">Issues</Heading>
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
- </Container>
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
- </div>
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
- <Button
215
- size="small"
216
- onClick={() => connectMutation.mutate()}
217
- isLoading={connectMutation.isPending}
218
- >
219
- Connect Faire
220
- </Button>
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
  )
@@ -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 space-joined `scope`. `state` is
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
- if (this.scope) params.set("scope", this.scope)
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.scope ? this.scope.split(/\s+/) : [],
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: data.next_page ?? data.pagination?.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
- * Faire does NOT expose a `GET /inventory` you can poll for remote counts —
291
- * inventory is write-only: `PATCH /product-inventory/by-skus` with an array
292
- * of `{ sku, current_count }` rows. (A `by-product-variant-ids` variant
293
- * exists for id-keyed overrides.)
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: data.next_page ?? data.pagination?.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: data.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: data.buyer_name ?? data.customer?.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
- export const DEFAULT_API_BASE = "https://faire.com/external-api/v2"
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?: string
75
- wholesale_price_cents?: number
76
- retail_price_cents?: number
77
- inventory_count?: number
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 of
120
- * per-SKU overrides; each row carries the SKU and the new on-hand count.
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
- current_count: number
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(),