@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
@@ -92,7 +92,15 @@ class FaireSyncService extends MedusaService({
92
92
  this.options_ = resolveFaireOptions(options)
93
93
  }
94
94
 
95
- getClient(): FaireClient {
95
+ /**
96
+ * Build a Faire client. Pass `authModeOverride` (e.g. an account's `auth_mode`)
97
+ * to force the auth header for that connection type; without it, the plugin's
98
+ * configured default is used (cached).
99
+ */
100
+ getClient(authModeOverride?: "oauth" | "apiKey"): FaireClient {
101
+ if (authModeOverride) {
102
+ return new FaireClient({ ...this.options_, authMode: authModeOverride })
103
+ }
96
104
  if (!this.client_) {
97
105
  this.client_ = new FaireClient(this.options_)
98
106
  }
@@ -128,7 +136,11 @@ class FaireSyncService extends MedusaService({
128
136
  }
129
137
  }
130
138
 
131
- async saveAccount(token: TokenData, brand: BrandInfo) {
139
+ async saveAccount(
140
+ token: TokenData,
141
+ brand: BrandInfo,
142
+ authMode: "oauth" | "apiKey" = "oauth"
143
+ ) {
132
144
  const existing = await this.getActiveAccount()
133
145
  const expiresAt =
134
146
  token.expires_in != null
@@ -139,6 +151,7 @@ class FaireSyncService extends MedusaService({
139
151
  brand_name: brand.brand_name,
140
152
  currency: brand.currency ?? null,
141
153
  country: brand.country ?? null,
154
+ auth_mode: authMode,
142
155
  access_token: encryptSecret(token.access_token),
143
156
  refresh_token: encryptSecret(token.refresh_token ?? null),
144
157
  token_expires_at: expiresAt,
@@ -163,8 +176,9 @@ class FaireSyncService extends MedusaService({
163
176
  // Best-effort: a failed revoke must not block local disconnect.
164
177
  try {
165
178
  const accessToken = decryptSecret((account as any).access_token)
166
- if (accessToken && this.options_.authMode !== "apiKey") {
167
- await this.getClient().revokeToken(accessToken)
179
+ // Only OAuth tokens are revocable; a brand-issued API key is not.
180
+ if (accessToken && (account as any).auth_mode !== "apiKey") {
181
+ await this.getClient("oauth").revokeToken(accessToken)
168
182
  }
169
183
  } catch (err: any) {
170
184
  // eslint-disable-next-line no-console
@@ -254,12 +268,8 @@ class FaireSyncService extends MedusaService({
254
268
  }
255
269
 
256
270
  async startOAuth(): Promise<{ authorization_url: string; state: string }> {
257
- if (this.options_.authMode === "apiKey") {
258
- throw new MedusaError(
259
- MedusaError.Types.INVALID_DATA,
260
- "Faire is configured in API-key mode — use /admin/faire/auth/api-key to connect, not OAuth."
261
- )
262
- }
271
+ // Both connection types are available per-account, so OAuth is always
272
+ // offered here; the API-key path is a separate entry point.
263
273
  try {
264
274
  const state = crypto.randomUUID()
265
275
  await this.updateSettings({
@@ -268,7 +278,7 @@ class FaireSyncService extends MedusaService({
268
278
  created_at: Date.now(),
269
279
  },
270
280
  })
271
- const client = this.getClient()
281
+ const client = this.getClient("oauth")
272
282
  return {
273
283
  authorization_url: client.getAuthorizationUrl(state),
274
284
  state,
@@ -290,14 +300,16 @@ class FaireSyncService extends MedusaService({
290
300
  )
291
301
  }
292
302
  try {
293
- const client = this.getClient()
303
+ // Force API-key auth (X-FAIRE-ACCESS-TOKEN) for this connection regardless
304
+ // of the plugin's default mode, and persist it so later API calls use it.
305
+ const client = this.getClient("apiKey")
294
306
  const brand = await client.getBrand(accessToken)
295
307
  const token: TokenData = {
296
308
  access_token: accessToken,
297
309
  token_type: "Bearer",
298
310
  retrieved_at: Date.now(),
299
311
  }
300
- const account = await this.saveAccount(token, brand)
312
+ const account = await this.saveAccount(token, brand, "apiKey")
301
313
  await this.updateSettings({
302
314
  account_id: account.id,
303
315
  default_brand_id: brand.brand_id,
@@ -312,18 +324,26 @@ class FaireSyncService extends MedusaService({
312
324
  async completeOAuth(code: string, state: string) {
313
325
  const settings = await this.getSettings()
314
326
  const pending: any = settings.pending_oauth
327
+ // eslint-disable-next-line no-console
328
+ console.info(
329
+ `[faire-sync] completeOAuth: received code=${code ? code.slice(0, 6) + "…" : "<none>"} ` +
330
+ `state=${state ? state.slice(0, 8) + "…" : "<none>"} ` +
331
+ `pending=${pending ? "state:" + String(pending.state).slice(0, 8) + "…" : "<none>"} ` +
332
+ `match=${!!pending && pending.state === state}`
333
+ )
315
334
  if (!pending || pending.state !== state) {
316
335
  throw new MedusaError(
317
336
  MedusaError.Types.INVALID_DATA,
318
- "Invalid or expired OAuth state. Please start the Faire connection again."
337
+ `Invalid or expired OAuth state (received "${state}", ` +
338
+ `pending ${pending ? `"${pending.state}"` : "<none>"}). Please start the Faire connection again.`
319
339
  )
320
340
  }
321
341
  let token: TokenData | undefined
322
342
  try {
323
- const client = this.getClient()
343
+ const client = this.getClient("oauth")
324
344
  token = await client.exchangeCodeForToken(code)
325
345
  const brand = await client.getBrand(token.access_token)
326
- const account = await this.saveAccount(token, brand)
346
+ const account = await this.saveAccount(token, brand, "oauth")
327
347
  await this.updateSettings({
328
348
  account_id: account.id,
329
349
  default_brand_id: brand.brand_id,
@@ -331,6 +351,12 @@ class FaireSyncService extends MedusaService({
331
351
  })
332
352
  return { account, brand }
333
353
  } catch (err) {
354
+ // eslint-disable-next-line no-console
355
+ console.error(
356
+ "[faire-sync] completeOAuth failed during exchange/brand/save:",
357
+ (err as any)?.message || err,
358
+ (err as any)?.body ? `| faire body: ${JSON.stringify((err as any).body)}` : ""
359
+ )
334
360
  // Self-heal a dropped OAuth so the next attempt starts clean:
335
361
  // 1. clear the pending state (otherwise it sticks and confuses retries), and
336
362
  // 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
  }
@@ -70,7 +70,7 @@ const processBatchStep = createStep(
70
70
  ): Promise<StepResponse<{ synced: number; failed: number }>> => {
71
71
  const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
72
72
  const account = await service.ensureFreshToken()
73
- const client = service.getClient()
73
+ const client = service.getClient((account as any).auth_mode)
74
74
 
75
75
  // Resolve the incremental window. Precedence:
76
76
  // explicit input > persisted high-water mark > full backfill (undefined).
@@ -26,7 +26,9 @@ type ResolvedConfig = {
26
26
  account_id: string
27
27
  brand_id: string
28
28
  access_token: string
29
+ auth_mode: "oauth" | "apiKey"
29
30
  currency: string | null
31
+ country: string | null
30
32
  settings: any
31
33
  }
32
34
 
@@ -54,6 +56,31 @@ const productDisplayUrl = (product: ProductResponse): string | null => {
54
56
  return `https://www.faire.com/brand/products/${product.product_token}`
55
57
  }
56
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
+
57
84
  // ── Step 1: resolve account (fresh token) + settings ─────────────────────
58
85
 
59
86
  const resolveFaireConfigStep = createStep(
@@ -66,7 +93,9 @@ const resolveFaireConfigStep = createStep(
66
93
  account_id: account.id,
67
94
  brand_id: account.brand_id,
68
95
  access_token: account.access_token,
96
+ auth_mode: (account.auth_mode as "oauth" | "apiKey") ?? "oauth",
69
97
  currency: account.currency ?? null,
98
+ country: account.country ?? null,
70
99
  settings,
71
100
  })
72
101
  }
@@ -77,7 +106,15 @@ const resolveFaireConfigStep = createStep(
77
106
  const prepareProductStep = createStep(
78
107
  "faire-prepare-product-step",
79
108
  async (
80
- 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
+ },
81
118
  { container }
82
119
  ): Promise<StepResponse<PreparedProduct>> => {
83
120
  const query = container.resolve(
@@ -97,6 +134,8 @@ const prepareProductStep = createStep(
97
134
  "tags.*",
98
135
  "variants.*",
99
136
  "variants.prices.*",
137
+ "variants.options.*",
138
+ "variants.options.option.*",
100
139
  "variants.inventory_items.*",
101
140
  "images.*",
102
141
  "variants.sku",
@@ -129,64 +168,121 @@ const prepareProductStep = createStep(
129
168
  const metadata = product.metadata || {}
130
169
  const variants: any[] = product.variants || []
131
170
 
132
- // Medusa v2 stores money as a whole decimal in the price's currency (NOT
133
- // minor units). Faire wants integer cents, so round to cents.
134
- const toCents = (amount: any): number => Math.round(Number(amount || 0) * 100)
171
+ // ── Currency + geo constraint (Faire prices are geo-scoped) ────────────
172
+ // Prefer the brand's own currency; fall back to the price currency Medusa
173
+ // stored, then EUR. Currency picks the price row + drives the geo default.
174
+ const currency = String(
175
+ metadata.faire_currency ||
176
+ input.currency ||
177
+ (variants.flatMap((v) => v.prices || [])[0]?.currency_code ?? "EUR")
178
+ ).toUpperCase()
179
+ const geoConstraint: { country?: string; country_group?: string } =
180
+ metadata.faire_geo_constraint && typeof metadata.faire_geo_constraint === "object"
181
+ ? metadata.faire_geo_constraint
182
+ : geoForCurrency(currency, input.country)
183
+
184
+ // Medusa v2 stores money as a whole decimal in the price's currency; Faire
185
+ // wants integer minor units (cents), so round.
186
+ const toMinor = (amount: any): number => Math.round(Number(amount || 0) * 100)
135
187
 
136
188
  const markupPercent =
137
189
  Number(metadata.faire_wholesale_markup_percent ?? settings.default_wholesale_markup_percent) ||
138
190
  0
139
191
 
140
- const buildVariant = (v: any) => {
141
- const prices: any[] = v.prices || []
142
- const retail = prices[0] ?? prices.find(Boolean)
143
- const retailCents = retail ? toCents(retail.amount) : 0
144
- const wholesaleCents =
192
+ // Pick the price row matching the chosen currency (fall back to first).
193
+ const pickPrice = (prices: any[]): any =>
194
+ prices.find((p) => String(p.currency_code || "").toUpperCase() === currency) ??
195
+ prices[0] ??
196
+ null
197
+
198
+ const buildVariant = (v: any, idx: number) => {
199
+ const retail = pickPrice(v.prices || [])
200
+ const retailMinor = retail ? toMinor(retail.amount) : 0
201
+ const wholesaleMinor =
145
202
  markupPercent > 0
146
- ? Math.round((retailCents * (100 - markupPercent)) / 100)
147
- : retailCents
203
+ ? Math.round((retailMinor * (100 - markupPercent)) / 100)
204
+ : retailMinor
205
+ const sku = v.sku || v.id
206
+ const options =
207
+ (v.options || [])
208
+ .map((o: any) => ({
209
+ name: String(o.option?.title || o.title || "Option"),
210
+ value: String(o.value ?? ""),
211
+ }))
212
+ .filter((o: any) => o.value) || []
148
213
  return {
149
- sku: v.sku || v.id,
150
- name: v.title || undefined,
151
- retail_price_cents: retailCents || undefined,
152
- wholesale_price_cents: wholesaleCents || undefined,
153
- inventory_count: Number(v.inventory_quantity) || 0,
214
+ sku,
215
+ name: v.title || sku,
216
+ idempotence_token: `${product.id}:${v.id || sku}`,
217
+ options: options.length ? options : undefined,
218
+ available_quantity: Number(v.inventory_quantity) || 0,
219
+ prices:
220
+ retailMinor > 0
221
+ ? [
222
+ {
223
+ geo_constraint: geoConstraint,
224
+ wholesale_price: { amount_minor: wholesaleMinor, currency },
225
+ retail_price: { amount_minor: retailMinor, currency },
226
+ },
227
+ ]
228
+ : undefined,
154
229
  }
155
230
  }
156
231
 
157
- // Aggregate retail/wholesale from min-variant for the product-level price.
158
- const allPrices = variants.flatMap((v) => v.prices || [])
159
- const minRetail =
160
- allPrices
161
- .map((p) => toCents(p.amount))
162
- .filter((a) => a > 0)
163
- .sort((a, b) => a - b)[0] ?? 0
164
- const minWholesale =
165
- markupPercent > 0
166
- ? Math.round((minRetail * (100 - markupPercent)) / 100)
167
- : minRetail
168
-
169
- const tags = (product.tags || [])
170
- .map((t: any) => t.value)
171
- .filter(Boolean)
172
-
173
232
  const images = (product.images || [])
174
233
  .map((img: any) => ({ url: img.url }))
175
234
  .filter((i: any) => i.url)
176
235
 
236
+ // Resolve taxonomy_type.id — REQUIRED by Faire create. Priority:
237
+ // product.metadata.faire_taxonomy_type_id (tt_… or a category name)
238
+ // → settings.default_category (id or name) → error.
239
+ const client = service.getClient(input.auth_mode)
240
+ const categoryHint =
241
+ metadata.faire_taxonomy_type_id ||
242
+ metadata.faire_category ||
243
+ settings.default_category ||
244
+ ""
245
+ const taxonomyTypeId = await client.resolveTaxonomyTypeId(
246
+ input.access_token,
247
+ String(categoryHint)
248
+ )
249
+ if (!taxonomyTypeId) {
250
+ throw new Error(
251
+ "Faire requires a product category (taxonomy_type). Set a Faire " +
252
+ "category in sync settings (default_category) or on the product " +
253
+ "(metadata.faire_taxonomy_type_id — a `tt_…` id or a category name)."
254
+ )
255
+ }
256
+
257
+ const lifecycle_state: "DRAFT" | "PUBLISHED" =
258
+ settings.follow_product_status !== false && product.status === "published"
259
+ ? "PUBLISHED"
260
+ : "DRAFT"
261
+
262
+ const builtVariants = (variants.length ? variants : [null]).map((v, i) =>
263
+ v
264
+ ? buildVariant(v, i)
265
+ : {
266
+ // Single-variant fallback for products with no explicit variants.
267
+ sku: product.id,
268
+ name: product.title || "Default",
269
+ idempotence_token: `${product.id}:default`,
270
+ available_quantity: 0,
271
+ }
272
+ )
273
+
177
274
  const product_input: CreateProductInput = {
178
- brand_id: input.brand_id,
179
275
  name: (product.title || "Untitled Product").slice(0, 140),
276
+ idempotence_token: product.id,
277
+ lifecycle_state,
278
+ taxonomy_type: { id: taxonomyTypeId },
180
279
  description: product.description || "",
181
- wholesale_price_cents: minWholesale || undefined,
182
- retail_price_cents: minRetail || undefined,
183
- images,
184
- variants: variants.length ? variants.map(buildVariant) : undefined,
185
- tags,
186
280
  short_description:
187
281
  typeof metadata.faire_short_description === "string"
188
282
  ? metadata.faire_short_description
189
283
  : undefined,
284
+ images,
285
+ variants: builtVariants,
190
286
  }
191
287
 
192
288
  return new StepResponse({
@@ -211,7 +307,7 @@ const syncProductStep = createStep(
211
307
  { container }
212
308
  ): Promise<StepResponse<SyncResult>> => {
213
309
  const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
214
- const client = service.getClient()
310
+ const client = service.getClient(input.config.auth_mode)
215
311
  const { access_token, settings } = input.config
216
312
  const { product_input, existing_product_token, product_status } =
217
313
  input.prepared
@@ -224,9 +320,11 @@ const syncProductStep = createStep(
224
320
  product = await client.updateProduct(access_token, existing_product_token, {
225
321
  name: product_input.name,
226
322
  description: product_input.description,
323
+ short_description: product_input.short_description,
324
+ lifecycle_state: product_input.lifecycle_state,
325
+ taxonomy_type: product_input.taxonomy_type,
227
326
  images: product_input.images,
228
327
  variants: product_input.variants,
229
- tags: product_input.tags,
230
328
  })
231
329
  } else {
232
330
  product = await client.createProduct(access_token, product_input)
@@ -242,7 +340,7 @@ const syncProductStep = createStep(
242
340
  try {
243
341
  const levels = product_input.variants.map((v) => ({
244
342
  sku: v.sku,
245
- current_count: v.inventory_count ?? 0,
343
+ on_hand_quantity: v.available_quantity ?? 0,
246
344
  }))
247
345
  await client.updateInventory(access_token, levels)
248
346
  } catch (err: any) {
@@ -346,6 +444,10 @@ export const syncProductToFaireWorkflow = createWorkflow(
346
444
  product_id: data.input.product_id,
347
445
  settings: data.config.settings,
348
446
  brand_id: data.config.brand_id,
447
+ currency: data.config.currency,
448
+ country: data.config.country,
449
+ access_token: data.config.access_token,
450
+ auth_mode: data.config.auth_mode,
349
451
  }))
350
452
  )
351
453