@jytextiles/medusa-plugin-faire-store-sync 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/__tests__/faire-client.spec.js +27 -7
- package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +61 -1
- package/.medusa/server/src/admin/index.js +182 -132
- package/.medusa/server/src/admin/index.mjs +184 -134
- package/.medusa/server/src/lib/faire-client.js +100 -18
- package/.medusa/server/src/lib/types.js +6 -2
- package/.medusa/server/src/modules/faire-sync/migrations/Migration20260709161201.js +14 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +5 -1
- package/.medusa/server/src/modules/faire-sync/service.js +33 -14
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +81 -28
- package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +2 -2
- package/.medusa/server/src/workflows/sync-product-to-faire.js +109 -37
- package/package.json +1 -1
- package/src/__tests__/faire-client.spec.ts +28 -6
- package/src/__tests__/ingest-faire-order-support.spec.ts +63 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +56 -61
- package/src/admin/routes/settings/faire/page.tsx +78 -8
- package/src/lib/faire-client.ts +112 -16
- package/src/lib/types.ts +57 -18
- package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +19 -0
- package/src/modules/faire-sync/migrations/Migration20260709161201.ts +13 -0
- package/src/modules/faire-sync/models/faire-sync-account.ts +4 -0
- package/src/modules/faire-sync/service.ts +42 -16
- package/src/workflows/ingest-faire-order-support.ts +96 -29
- package/src/workflows/ingest-faire-orders-bulk.ts +1 -1
- package/src/workflows/sync-product-to-faire.ts +143 -41
|
@@ -92,7 +92,15 @@ class FaireSyncService extends MedusaService({
|
|
|
92
92
|
this.options_ = resolveFaireOptions(options)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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: {
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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((
|
|
147
|
-
:
|
|
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
|
|
150
|
-
name: v.title ||
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|