@jytextiles/medusa-plugin-faire-store-sync 0.2.5 → 0.2.7

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/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 {
@@ -253,6 +253,13 @@ class FaireSyncService extends MedusaService({
253
253
  return this.updateFaireSyncSettings({ id: settings.id, ...data } as any)
254
254
  }
255
255
 
256
+ async getTaxonomyTypes(): Promise<Array<{ id: string; name: string }>> {
257
+ const account = await this.getActiveAccount()
258
+ if (!account) return []
259
+ const client = this.getClient(account.auth_mode as "oauth" | "apiKey")
260
+ return client.getTaxonomyTypes(account.access_token)
261
+ }
262
+
256
263
  /**
257
264
  * High-water mark for incremental order polling. Returns the last successful
258
265
  * sync timestamp, or null if orders have never been polled (full backfill).
@@ -324,15 +331,23 @@ class FaireSyncService extends MedusaService({
324
331
  async completeOAuth(code: string, state: string) {
325
332
  const settings = await this.getSettings()
326
333
  const pending: any = settings.pending_oauth
334
+ // eslint-disable-next-line no-console
335
+ console.info(
336
+ `[faire-sync] completeOAuth: received code=${code ? code.slice(0, 6) + "…" : "<none>"} ` +
337
+ `state=${state ? state.slice(0, 8) + "…" : "<none>"} ` +
338
+ `pending=${pending ? "state:" + String(pending.state).slice(0, 8) + "…" : "<none>"} ` +
339
+ `match=${!!pending && pending.state === state}`
340
+ )
327
341
  if (!pending || pending.state !== state) {
328
342
  throw new MedusaError(
329
343
  MedusaError.Types.INVALID_DATA,
330
- "Invalid or expired OAuth state. Please start the Faire connection again."
344
+ `Invalid or expired OAuth state (received "${state}", ` +
345
+ `pending ${pending ? `"${pending.state}"` : "<none>"}). Please start the Faire connection again.`
331
346
  )
332
347
  }
333
348
  let token: TokenData | undefined
334
349
  try {
335
- const client = this.getClient()
350
+ const client = this.getClient("oauth")
336
351
  token = await client.exchangeCodeForToken(code)
337
352
  const brand = await client.getBrand(token.access_token)
338
353
  const account = await this.saveAccount(token, brand, "oauth")
@@ -343,6 +358,12 @@ class FaireSyncService extends MedusaService({
343
358
  })
344
359
  return { account, brand }
345
360
  } catch (err) {
361
+ // eslint-disable-next-line no-console
362
+ console.error(
363
+ "[faire-sync] completeOAuth failed during exchange/brand/save:",
364
+ (err as any)?.message || err,
365
+ (err as any)?.body ? `| faire body: ${JSON.stringify((err as any).body)}` : ""
366
+ )
346
367
  // Self-heal a dropped OAuth so the next attempt starts clean:
347
368
  // 1. clear the pending state (otherwise it sticks and confuses retries), and
348
369
  // 2. if the code exchange succeeded but a later step failed, REVOKE the
@@ -18,6 +18,31 @@ const splitName = (name?: string): { first: string; last: string } => {
18
18
  return { first: parts[0], last: parts.slice(1).join(" ") }
19
19
  }
20
20
 
21
+ // Faire ExternalMoneyV2 = { amount_minor, currency }. Returns integer minor
22
+ // units + ISO currency, or null when the field is absent/not a money object.
23
+ const moneyOf = (m: any): { minor: number; currency: string } | null =>
24
+ m && typeof m === "object" && m.amount_minor != null
25
+ ? { minor: Number(m.amount_minor) || 0, currency: String(m.currency || "") }
26
+ : null
27
+
28
+ // Faire order addresses carry ISO-3 `country_code` ("CAN") + a full `country`
29
+ // name ("Canada"); Medusa wants a lowercase ISO-2. Map the ISO-3 codes for the
30
+ // regions Faire operates in; pass through anything already 2-letter.
31
+ const ISO3_TO_ISO2: Record<string, string> = {
32
+ USA: "us", CAN: "ca", GBR: "gb", AUS: "au", NZL: "nz", DEU: "de", FRA: "fr",
33
+ ITA: "it", ESP: "es", PRT: "pt", NLD: "nl", BEL: "be", LUX: "lu", AUT: "at",
34
+ IRL: "ie", CHE: "ch", SWE: "se", DNK: "dk", FIN: "fi", NOR: "no", POL: "pl",
35
+ CZE: "cz", GRC: "gr", HUN: "hu", ROU: "ro", BGR: "bg", HRV: "hr", SVK: "sk",
36
+ SVN: "si", EST: "ee", LVA: "lv", LTU: "lt", CYP: "cy", MLT: "mt",
37
+ }
38
+ const faireCountryToIso2 = (raw?: string): string | undefined => {
39
+ const s = String(raw ?? "").trim()
40
+ if (!s) return undefined
41
+ if (s.length === 2) return s.toLowerCase()
42
+ const iso2 = ISO3_TO_ISO2[s.toUpperCase()]
43
+ return iso2 ?? undefined // full country names ("Canada") can't be mapped safely
44
+ }
45
+
21
46
  export type MappedFaireOrder = {
22
47
  order_token: string
23
48
  email: string
@@ -37,12 +62,31 @@ export function mapFaireOrderToOrder(order: any): MappedFaireOrder {
37
62
  const orderToken = String(order?.order_token ?? order?.id ?? order?.token ?? "")
38
63
 
39
64
  const address = order?.address ?? order?.shipping_address ?? {}
40
- const currency = String(order?.currency ?? order?.currency_code ?? "usd").toLowerCase()
65
+ const items: any[] = Array.isArray(order?.items) ? order.items : []
41
66
 
67
+ // ExternalOrderV2 has NO top-level currency/total. Currency comes off any
68
+ // item's ExternalMoneyV2 `price` (fall back to the payout currency, then the
69
+ // legacy top-level fields for back-compat). See docs verified 2026-07-09.
70
+ const itemMoney = items.map((it) => moneyOf(it?.price)).find(Boolean)
71
+ const currency = String(
72
+ itemMoney?.currency ||
73
+ moneyOf(order?.payout_costs?.total_payout)?.currency ||
74
+ order?.currency ||
75
+ order?.currency_code ||
76
+ "usd"
77
+ ).toLowerCase()
78
+
79
+ // Buyer name: ExternalOrderV2.customer { first_name, last_name }, else the
80
+ // recipient name on the address. (`address.company_name`/`retailer_id` is the
81
+ // wholesale store, not the person.) Legacy fields kept as fallbacks.
82
+ const customer = order?.customer ?? {}
42
83
  const buyerName =
43
- order?.buyer_name ??
44
- order?.customer_name ??
45
- [address?.first_name, address?.last_name].filter(Boolean).join(" ")
84
+ [customer?.first_name, customer?.last_name].filter(Boolean).join(" ") ||
85
+ address?.name ||
86
+ order?.buyer_name ||
87
+ order?.customer_name ||
88
+ [address?.first_name, address?.last_name].filter(Boolean).join(" ") ||
89
+ ""
46
90
  const { first, last } = splitName(buyerName)
47
91
 
48
92
  // Faire usually does NOT expose the retailer's email via API — fall back to a
@@ -53,35 +97,58 @@ export function mapFaireOrderToOrder(order: any): MappedFaireOrder {
53
97
  (typeof order?.email === "string" && order.email) ||
54
98
  `faire+${orderToken}@marketplace.invalid`
55
99
 
56
- const items: any[] = Array.isArray(order?.items) ? order.items : []
57
- const mappedItems = items.map((it) => {
58
- const unitCents = Number(it?.wholesale_price_cents ?? it?.unit_price_cents ?? 0)
59
- return {
60
- title: String(it?.product_name ?? it?.name ?? it?.title ?? "Faire item").slice(0, 250),
61
- quantity: Math.max(1, Number(it?.quantity ?? it?.qty) || 1),
62
- unit_price: faireMoney(unitCents),
63
- metadata: {
64
- faire_order_token: orderToken,
65
- faire_item_id: it?.id != null ? String(it.id) : null,
66
- faire_product_token:
67
- it?.product_token != null ? String(it.product_token) : null,
68
- faire_sku: it?.sku ?? null,
69
- faire_variant_id:
70
- it?.variant_id != null ? String(it.variant_id) : null,
71
- },
72
- }
73
- })
100
+ const unitMinorOf = (it: any): number => {
101
+ const m = moneyOf(it?.price)
102
+ return m
103
+ ? m.minor
104
+ : Number(it?.price_cents ?? it?.wholesale_price_cents ?? it?.unit_price_cents ?? 0)
105
+ }
106
+
107
+ const mappedItems = items.map((it) => ({
108
+ title: String(it?.product_name ?? it?.name ?? it?.title ?? "Faire item").slice(0, 250),
109
+ quantity: Math.max(1, Number(it?.quantity ?? it?.qty) || 1),
110
+ unit_price: faireMoney(unitMinorOf(it)),
111
+ metadata: {
112
+ faire_order_token: orderToken,
113
+ faire_item_id: it?.id != null ? String(it.id) : null,
114
+ faire_product_token:
115
+ it?.product_id != null
116
+ ? String(it.product_id)
117
+ : it?.product_token != null
118
+ ? String(it.product_token)
119
+ : null,
120
+ faire_sku: it?.sku ?? null,
121
+ faire_variant_id: it?.variant_id != null ? String(it.variant_id) : null,
122
+ },
123
+ }))
124
+
125
+ // No order-level total exists — sum line values (unit price × qty). Honour an
126
+ // explicit total_cents/grand_total_cents if a caller ever supplies one.
127
+ const explicitTotalCents = order?.total_cents ?? order?.grand_total_cents
128
+ const total =
129
+ explicitTotalCents != null
130
+ ? faireMoney(explicitTotalCents)
131
+ : faireMoney(
132
+ items.reduce(
133
+ (sum, it) =>
134
+ sum + unitMinorOf(it) * Math.max(1, Number(it?.quantity ?? it?.qty) || 1),
135
+ 0
136
+ )
137
+ )
74
138
 
75
139
  const shipping_address = {
76
140
  first_name: address?.first_name ?? first,
77
141
  last_name: address?.last_name ?? last,
78
- address_1: address?.street1 ?? address?.address1 ?? address?.address_1 ?? "",
79
- address_2: address?.street2 ?? address?.address2 ?? address?.address_2 ?? "",
142
+ company: address?.company_name ?? undefined,
143
+ address_1:
144
+ address?.address1 ?? address?.address_1 ?? address?.street1 ?? "",
145
+ address_2:
146
+ address?.address2 ?? address?.address_2 ?? address?.street2 ?? "",
80
147
  city: address?.city ?? "",
81
- province: address?.state ?? address?.province ?? "",
82
- postal_code: address?.zip ?? address?.postal_code ?? "",
83
- country_code: String(address?.country ?? address?.country_code ?? "").toLowerCase() || undefined,
84
- phone: address?.phone ?? undefined,
148
+ province: address?.state_code ?? address?.state ?? address?.province ?? "",
149
+ postal_code: address?.postal_code ?? address?.zip ?? "",
150
+ country_code: faireCountryToIso2(address?.country_code ?? address?.country),
151
+ phone: address?.phone_number ?? address?.phone ?? undefined,
85
152
  }
86
153
 
87
154
  return {
@@ -89,7 +156,7 @@ export function mapFaireOrderToOrder(order: any): MappedFaireOrder {
89
156
  email,
90
157
  buyer_name: buyerName || null,
91
158
  currency_code: currency,
92
- total: faireMoney(order?.total_cents ?? order?.grand_total_cents),
159
+ total,
93
160
  shipping_address,
94
161
  items: mappedItems,
95
162
  }
@@ -6,7 +6,7 @@ import {
6
6
  WorkflowData,
7
7
  transform,
8
8
  } from "@medusajs/framework/workflows-sdk"
9
- import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
9
+ import { ContainerRegistrationKeys, MedusaError, Modules } from "@medusajs/framework/utils"
10
10
  import type { Link } from "@medusajs/modules-sdk"
11
11
  import type { RemoteQueryFunction } from "@medusajs/types"
12
12
  import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
@@ -28,6 +28,7 @@ type ResolvedConfig = {
28
28
  access_token: string
29
29
  auth_mode: "oauth" | "apiKey"
30
30
  currency: string | null
31
+ country: string | null
31
32
  settings: any
32
33
  }
33
34
 
@@ -55,6 +56,31 @@ const productDisplayUrl = (product: ProductResponse): string | null => {
55
56
  return `https://www.faire.com/brand/products/${product.product_token}`
56
57
  }
57
58
 
59
+ /**
60
+ * Faire variant prices are geo-scoped (`geo_constraint`). Derive a sensible
61
+ * default from the brand's currency (and country when known). Eurozone brands
62
+ * map to the EUROPEAN_UNION country_group; otherwise scope to a single country.
63
+ */
64
+ const geoForCurrency = (
65
+ currency: string,
66
+ country: string | null
67
+ ): { country?: string; country_group?: string } => {
68
+ switch (currency) {
69
+ case "EUR":
70
+ return { country_group: "EUROPEAN_UNION" }
71
+ case "GBP":
72
+ return { country: "GB" }
73
+ case "USD":
74
+ return { country: "US" }
75
+ case "CAD":
76
+ return { country: "CA" }
77
+ case "AUD":
78
+ return { country: "AU" }
79
+ default:
80
+ return country ? { country } : { country_group: "EUROPEAN_UNION" }
81
+ }
82
+ }
83
+
58
84
  // ── Step 1: resolve account (fresh token) + settings ─────────────────────
59
85
 
60
86
  const resolveFaireConfigStep = createStep(
@@ -69,6 +95,7 @@ const resolveFaireConfigStep = createStep(
69
95
  access_token: account.access_token,
70
96
  auth_mode: (account.auth_mode as "oauth" | "apiKey") ?? "oauth",
71
97
  currency: account.currency ?? null,
98
+ country: account.country ?? null,
72
99
  settings,
73
100
  })
74
101
  }
@@ -79,7 +106,15 @@ const resolveFaireConfigStep = createStep(
79
106
  const prepareProductStep = createStep(
80
107
  "faire-prepare-product-step",
81
108
  async (
82
- input: { product_id: string; settings: any; brand_id: string },
109
+ input: {
110
+ product_id: string
111
+ settings: any
112
+ brand_id: string
113
+ currency: string | null
114
+ country: string | null
115
+ access_token: string
116
+ auth_mode: "oauth" | "apiKey"
117
+ },
83
118
  { container }
84
119
  ): Promise<StepResponse<PreparedProduct>> => {
85
120
  const query = container.resolve(
@@ -99,6 +134,8 @@ const prepareProductStep = createStep(
99
134
  "tags.*",
100
135
  "variants.*",
101
136
  "variants.prices.*",
137
+ "variants.options.*",
138
+ "variants.options.option.*",
102
139
  "variants.inventory_items.*",
103
140
  "images.*",
104
141
  "variants.sku",
@@ -108,7 +145,10 @@ const prepareProductStep = createStep(
108
145
 
109
146
  const product: any = products?.[0]
110
147
  if (!product) {
111
- throw new Error(`Product ${input.product_id} not found`)
148
+ throw new MedusaError(
149
+ MedusaError.Types.NOT_FOUND,
150
+ `Product ${input.product_id} not found`
151
+ )
112
152
  }
113
153
 
114
154
  // Resolve existing Faire product (from link)
@@ -131,64 +171,122 @@ const prepareProductStep = createStep(
131
171
  const metadata = product.metadata || {}
132
172
  const variants: any[] = product.variants || []
133
173
 
134
- // Medusa v2 stores money as a whole decimal in the price's currency (NOT
135
- // minor units). Faire wants integer cents, so round to cents.
136
- const toCents = (amount: any): number => Math.round(Number(amount || 0) * 100)
174
+ // ── Currency + geo constraint (Faire prices are geo-scoped) ────────────
175
+ // Prefer the brand's own currency; fall back to the price currency Medusa
176
+ // stored, then EUR. Currency picks the price row + drives the geo default.
177
+ const currency = String(
178
+ metadata.faire_currency ||
179
+ input.currency ||
180
+ (variants.flatMap((v) => v.prices || [])[0]?.currency_code ?? "EUR")
181
+ ).toUpperCase()
182
+ const geoConstraint: { country?: string; country_group?: string } =
183
+ metadata.faire_geo_constraint && typeof metadata.faire_geo_constraint === "object"
184
+ ? metadata.faire_geo_constraint
185
+ : geoForCurrency(currency, input.country)
186
+
187
+ // Medusa v2 stores money as a whole decimal in the price's currency; Faire
188
+ // wants integer minor units (cents), so round.
189
+ const toMinor = (amount: any): number => Math.round(Number(amount || 0) * 100)
137
190
 
138
191
  const markupPercent =
139
192
  Number(metadata.faire_wholesale_markup_percent ?? settings.default_wholesale_markup_percent) ||
140
193
  0
141
194
 
142
- const buildVariant = (v: any) => {
143
- const prices: any[] = v.prices || []
144
- const retail = prices[0] ?? prices.find(Boolean)
145
- const retailCents = retail ? toCents(retail.amount) : 0
146
- const wholesaleCents =
195
+ // Pick the price row matching the chosen currency (fall back to first).
196
+ const pickPrice = (prices: any[]): any =>
197
+ prices.find((p) => String(p.currency_code || "").toUpperCase() === currency) ??
198
+ prices[0] ??
199
+ null
200
+
201
+ const buildVariant = (v: any, idx: number) => {
202
+ const retail = pickPrice(v.prices || [])
203
+ const retailMinor = retail ? toMinor(retail.amount) : 0
204
+ const wholesaleMinor =
147
205
  markupPercent > 0
148
- ? Math.round((retailCents * (100 - markupPercent)) / 100)
149
- : retailCents
206
+ ? Math.round((retailMinor * (100 - markupPercent)) / 100)
207
+ : retailMinor
208
+ const sku = v.sku || v.id
209
+ const options =
210
+ (v.options || [])
211
+ .map((o: any) => ({
212
+ name: String(o.option?.title || o.title || "Option"),
213
+ value: String(o.value ?? ""),
214
+ }))
215
+ .filter((o: any) => o.value) || []
150
216
  return {
151
- sku: v.sku || v.id,
152
- name: v.title || undefined,
153
- retail_price_cents: retailCents || undefined,
154
- wholesale_price_cents: wholesaleCents || undefined,
155
- inventory_count: Number(v.inventory_quantity) || 0,
217
+ sku,
218
+ name: v.title || sku,
219
+ idempotence_token: `${product.id}:${v.id || sku}`,
220
+ options: options.length ? options : undefined,
221
+ available_quantity: Number(v.inventory_quantity) || 0,
222
+ prices:
223
+ retailMinor > 0
224
+ ? [
225
+ {
226
+ geo_constraint: geoConstraint,
227
+ wholesale_price: { amount_minor: wholesaleMinor, currency },
228
+ retail_price: { amount_minor: retailMinor, currency },
229
+ },
230
+ ]
231
+ : undefined,
156
232
  }
157
233
  }
158
234
 
159
- // Aggregate retail/wholesale from min-variant for the product-level price.
160
- const allPrices = variants.flatMap((v) => v.prices || [])
161
- const minRetail =
162
- allPrices
163
- .map((p) => toCents(p.amount))
164
- .filter((a) => a > 0)
165
- .sort((a, b) => a - b)[0] ?? 0
166
- const minWholesale =
167
- markupPercent > 0
168
- ? Math.round((minRetail * (100 - markupPercent)) / 100)
169
- : minRetail
170
-
171
- const tags = (product.tags || [])
172
- .map((t: any) => t.value)
173
- .filter(Boolean)
174
-
175
235
  const images = (product.images || [])
176
236
  .map((img: any) => ({ url: img.url }))
177
237
  .filter((i: any) => i.url)
178
238
 
239
+ // Resolve taxonomy_type.id — REQUIRED by Faire create. Priority:
240
+ // product.metadata.faire_taxonomy_type_id (tt_… or a category name)
241
+ // → settings.default_category (id or name) → error.
242
+ const client = service.getClient(input.auth_mode)
243
+ const categoryHint =
244
+ metadata.faire_taxonomy_type_id ||
245
+ metadata.faire_category ||
246
+ settings.default_category ||
247
+ ""
248
+ const taxonomyTypeId = await client.resolveTaxonomyTypeId(
249
+ input.access_token,
250
+ String(categoryHint)
251
+ )
252
+ if (!taxonomyTypeId) {
253
+ throw new MedusaError(
254
+ MedusaError.Types.INVALID_DATA,
255
+ "Faire requires a product category (taxonomy_type). Set a Faire " +
256
+ "category in sync settings (default_category) or on the product " +
257
+ "(metadata.faire_taxonomy_type_id — a `tt_…` id or a category name)."
258
+ )
259
+ }
260
+
261
+ const lifecycle_state: "DRAFT" | "PUBLISHED" =
262
+ settings.follow_product_status !== false && product.status === "published"
263
+ ? "PUBLISHED"
264
+ : "DRAFT"
265
+
266
+ const builtVariants = (variants.length ? variants : [null]).map((v, i) =>
267
+ v
268
+ ? buildVariant(v, i)
269
+ : {
270
+ // Single-variant fallback for products with no explicit variants.
271
+ sku: product.id,
272
+ name: product.title || "Default",
273
+ idempotence_token: `${product.id}:default`,
274
+ available_quantity: 0,
275
+ }
276
+ )
277
+
179
278
  const product_input: CreateProductInput = {
180
- brand_id: input.brand_id,
181
279
  name: (product.title || "Untitled Product").slice(0, 140),
280
+ idempotence_token: product.id,
281
+ lifecycle_state,
282
+ taxonomy_type: { id: taxonomyTypeId },
182
283
  description: product.description || "",
183
- wholesale_price_cents: minWholesale || undefined,
184
- retail_price_cents: minRetail || undefined,
185
- images,
186
- variants: variants.length ? variants.map(buildVariant) : undefined,
187
- tags,
188
284
  short_description:
189
285
  typeof metadata.faire_short_description === "string"
190
286
  ? metadata.faire_short_description
191
287
  : undefined,
288
+ images,
289
+ variants: builtVariants,
192
290
  }
193
291
 
194
292
  return new StepResponse({
@@ -226,9 +324,11 @@ const syncProductStep = createStep(
226
324
  product = await client.updateProduct(access_token, existing_product_token, {
227
325
  name: product_input.name,
228
326
  description: product_input.description,
327
+ short_description: product_input.short_description,
328
+ lifecycle_state: product_input.lifecycle_state,
329
+ taxonomy_type: product_input.taxonomy_type,
229
330
  images: product_input.images,
230
331
  variants: product_input.variants,
231
- tags: product_input.tags,
232
332
  })
233
333
  } else {
234
334
  product = await client.createProduct(access_token, product_input)
@@ -244,7 +344,7 @@ const syncProductStep = createStep(
244
344
  try {
245
345
  const levels = product_input.variants.map((v) => ({
246
346
  sku: v.sku,
247
- current_count: v.inventory_count ?? 0,
347
+ on_hand_quantity: v.available_quantity ?? 0,
248
348
  }))
249
349
  await client.updateInventory(access_token, levels)
250
350
  } catch (err: any) {
@@ -348,6 +448,10 @@ export const syncProductToFaireWorkflow = createWorkflow(
348
448
  product_id: data.input.product_id,
349
449
  settings: data.config.settings,
350
450
  brand_id: data.config.brand_id,
451
+ currency: data.config.currency,
452
+ country: data.config.country,
453
+ access_token: data.config.access_token,
454
+ auth_mode: data.config.auth_mode,
351
455
  }))
352
456
  )
353
457