@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/.medusa/server/src/__tests__/faire-client.spec.js +124 -7
- package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +61 -1
- package/.medusa/server/src/admin/index.js +167 -137
- package/.medusa/server/src/admin/index.mjs +168 -138
- package/.medusa/server/src/api/admin/faire/status/route.js +4 -2
- package/.medusa/server/src/api/admin/faire/taxonomy/route.js +12 -0
- package/.medusa/server/src/lib/faire-client.js +120 -18
- package/.medusa/server/src/lib/types.js +6 -2
- package/.medusa/server/src/modules/faire-sync/service.js +18 -3
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +81 -28
- package/.medusa/server/src/workflows/sync-product-to-faire.js +108 -37
- package/package.json +1 -1
- package/src/__tests__/faire-client.spec.ts +135 -6
- package/src/__tests__/ingest-faire-order-support.spec.ts +63 -0
- package/src/admin/lib/api.ts +4 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +56 -61
- package/src/admin/routes/settings/faire/settings/page.tsx +62 -13
- package/src/api/admin/faire/status/route.ts +3 -1
- package/src/api/admin/faire/taxonomy/route.ts +10 -0
- package/src/lib/faire-client.ts +139 -16
- package/src/lib/types.ts +57 -18
- package/src/modules/faire-sync/service.ts +23 -2
- package/src/workflows/ingest-faire-order-support.ts +96 -29
- package/src/workflows/sync-product-to-faire.ts +146 -42
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 {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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?.
|
|
83
|
-
country_code:
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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((
|
|
149
|
-
:
|
|
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
|
|
152
|
-
name: v.title ||
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|