@jytextiles/medusa-plugin-faire-store-sync 0.1.0
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__/ingest-faire-order-support.spec.js +74 -0
- package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
- package/.medusa/server/src/admin/index.js +960 -0
- package/.medusa/server/src/admin/index.mjs +959 -0
- package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
- package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
- package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
- package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
- package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
- package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
- package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
- package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
- package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
- package/.medusa/server/src/lib/faire-client.js +321 -0
- package/.medusa/server/src/lib/types.js +10 -0
- package/.medusa/server/src/lib/webhook.js +58 -0
- package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
- package/.medusa/server/src/modules/faire-sync/index.js +15 -0
- package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
- package/.medusa/server/src/modules/faire-sync/service.js +222 -0
- package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
- package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
- package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
- package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
- package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
- package/.medusa/server/src/workflows/index.js +21 -0
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
- package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
- package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
- package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
- package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
- package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +84 -0
- package/scripts/postbuild.js +36 -0
- package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
- package/src/__tests__/webhook.spec.ts +81 -0
- package/src/admin/lib/api.ts +88 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
- package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
- package/src/admin/routes/settings/faire/page.tsx +445 -0
- package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
- package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
- package/src/admin/widgets/faire-product-widget.tsx +180 -0
- package/src/api/admin/faire/auth/authorize/route.ts +10 -0
- package/src/api/admin/faire/auth/callback/route.ts +29 -0
- package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
- package/src/api/admin/faire/brand/route.ts +12 -0
- package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
- package/src/api/admin/faire/ingest/orders/route.ts +29 -0
- package/src/api/admin/faire/products/route.ts +14 -0
- package/src/api/admin/faire/settings/route.ts +17 -0
- package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
- package/src/api/admin/faire/status/route.ts +47 -0
- package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
- package/src/api/admin/faire/sync/bulk/route.ts +27 -0
- package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
- package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
- package/src/api/admin/faire/syncs/route.ts +17 -0
- package/src/api/webhooks/faire/route.ts +133 -0
- package/src/jobs/pull-faire-orders.ts +30 -0
- package/src/jobs/refresh-faire-token.ts +23 -0
- package/src/lib/faire-client.ts +436 -0
- package/src/lib/types.ts +117 -0
- package/src/lib/webhook.ts +85 -0
- package/src/links/product-faire-sync-link.ts +56 -0
- package/src/modules/faire-sync/index.ts +11 -0
- package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
- package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
- package/src/modules/faire-sync/models/faire-order.ts +20 -0
- package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
- package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
- package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
- package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
- package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
- package/src/modules/faire-sync/service.ts +252 -0
- package/src/subscribers/faire-inventory-sync.ts +97 -0
- package/src/subscribers/faire-listing-events.ts +48 -0
- package/src/subscribers/faire-order-ingest.ts +61 -0
- package/src/subscribers/faire-order-lifecycle.ts +81 -0
- package/src/subscribers/faire-product-status-sync.ts +76 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/ingest-faire-order-support.ts +96 -0
- package/src/workflows/ingest-faire-order.ts +212 -0
- package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
- package/src/workflows/refresh-faire-token.ts +28 -0
- package/src/workflows/sync-product-to-faire.ts +375 -0
- package/src/workflows/sync-products-to-faire.ts +133 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
|
2
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
3
|
+
import { cancelOrderWorkflow } from "@medusajs/medusa/core-flows"
|
|
4
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
5
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Follow a Faire order's lifecycle onto the Medusa order we created for it:
|
|
9
|
+
* order.canceled → cancel the Medusa order
|
|
10
|
+
* order.shipped → stamp fulfillment status = shipped
|
|
11
|
+
* order.delivered→ stamp fulfillment status = delivered
|
|
12
|
+
*
|
|
13
|
+
* The order must already exist (created on order.placed); if it doesn't yet we
|
|
14
|
+
* simply skip.
|
|
15
|
+
*/
|
|
16
|
+
export default async function faireOrderLifecycleHandler({
|
|
17
|
+
event,
|
|
18
|
+
container,
|
|
19
|
+
}: SubscriberArgs<{ resource?: any; resource_url?: string }>) {
|
|
20
|
+
const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
21
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
22
|
+
|
|
23
|
+
let order: any = event.data?.resource
|
|
24
|
+
if (!order && event.data?.resource_url) {
|
|
25
|
+
try {
|
|
26
|
+
const account = await service.ensureFreshToken()
|
|
27
|
+
order = await service
|
|
28
|
+
.getClient()
|
|
29
|
+
.fetchResource((account as any).access_token, event.data.resource_url)
|
|
30
|
+
} catch {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const orderToken =
|
|
35
|
+
order?.order_token != null ? String(order.order_token) : null
|
|
36
|
+
if (!orderToken) return
|
|
37
|
+
|
|
38
|
+
const [mapRow] = await service
|
|
39
|
+
.listFaireOrders({ order_token: orderToken } as any)
|
|
40
|
+
.catch(() => [])
|
|
41
|
+
const orderId = (mapRow as any)?.order_id
|
|
42
|
+
if (!orderId) return
|
|
43
|
+
|
|
44
|
+
const orderService: any = container.resolve(Modules.ORDER)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (event.name === "faire.order_canceled") {
|
|
48
|
+
await cancelOrderWorkflow(container).run({ input: { order_id: orderId } })
|
|
49
|
+
await service.updateFaireOrders({ id: (mapRow as any).id, status: "canceled" } as any)
|
|
50
|
+
logger?.info?.(`[faire-order] canceled Medusa order ${orderId} (Faire ${orderToken})`)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fulfillmentStatus =
|
|
55
|
+
event.name === "faire.order_delivered" ? "delivered" : "shipped"
|
|
56
|
+
|
|
57
|
+
const existing = await orderService
|
|
58
|
+
.retrieveOrder(orderId, { select: ["id", "metadata"] })
|
|
59
|
+
.catch(() => null)
|
|
60
|
+
const metadata = {
|
|
61
|
+
...((existing?.metadata as any) || {}),
|
|
62
|
+
faire_fulfillment_status: fulfillmentStatus,
|
|
63
|
+
}
|
|
64
|
+
await orderService.updateOrders([{ id: orderId, metadata }])
|
|
65
|
+
await service.updateFaireOrders({
|
|
66
|
+
id: (mapRow as any).id,
|
|
67
|
+
status: fulfillmentStatus,
|
|
68
|
+
} as any)
|
|
69
|
+
logger?.info?.(
|
|
70
|
+
`[faire-order] order ${orderId} fulfillment → ${fulfillmentStatus} (Faire ${orderToken})`
|
|
71
|
+
)
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
logger?.warn?.(
|
|
74
|
+
`[faire-order] lifecycle ${event.name} failed for order ${orderId}: ${err?.message}`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const config: SubscriberConfig = {
|
|
80
|
+
event: ["faire.order_canceled", "faire.order_shipped", "faire.order_delivered"],
|
|
81
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
|
2
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
3
|
+
import type { Link } from "@medusajs/modules-sdk"
|
|
4
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
5
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
6
|
+
import { syncProductToFaireWorkflow } from "../workflows/sync-product-to-faire"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keep an already-synced Faire product in step with its Medusa product's status
|
|
10
|
+
* (the draft → published transition the user cares about).
|
|
11
|
+
*
|
|
12
|
+
* This is an internal Medusa event (Faire has its own outbound webhooks for
|
|
13
|
+
* product changes, handled separately). `product.updated` is high-frequency,
|
|
14
|
+
* so we guard hard to avoid burning Faire's per-second / daily quota:
|
|
15
|
+
* - only products that are ALREADY linked (i.e. synced at least once),
|
|
16
|
+
* - only when `follow_product_status` is on, and
|
|
17
|
+
* - only when the Medusa status ACTUALLY changed since the last sync
|
|
18
|
+
* (compared against `product_status` stored on the link metadata).
|
|
19
|
+
*
|
|
20
|
+
* The sync workflow does not mutate the Medusa product, so this cannot loop.
|
|
21
|
+
*/
|
|
22
|
+
export default async function faireProductStatusSyncHandler({
|
|
23
|
+
event: { data },
|
|
24
|
+
container,
|
|
25
|
+
}: SubscriberArgs<{ id: string }>) {
|
|
26
|
+
if (!data?.id) return
|
|
27
|
+
|
|
28
|
+
const logger = container.resolve(ContainerRegistrationKeys.LOGGER) as any
|
|
29
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
30
|
+
|
|
31
|
+
const account = await service.getActiveAccount()
|
|
32
|
+
if (!account) return
|
|
33
|
+
|
|
34
|
+
const settings = await service.getSettings()
|
|
35
|
+
if ((settings as any).follow_product_status === false) return
|
|
36
|
+
|
|
37
|
+
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK) as Link
|
|
38
|
+
let row: any
|
|
39
|
+
try {
|
|
40
|
+
const rows = await remoteLink.list({
|
|
41
|
+
[Modules.PRODUCT]: { product_id: data.id },
|
|
42
|
+
[FAIRE_SYNC_MODULE]: { faire_sync_account_id: account.id },
|
|
43
|
+
})
|
|
44
|
+
row = (rows as any[])?.[0]
|
|
45
|
+
} catch {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (!row?.faire_product_token) return
|
|
49
|
+
|
|
50
|
+
const query = container.resolve(ContainerRegistrationKeys.QUERY) as any
|
|
51
|
+
const { data: products } = await query.graph({
|
|
52
|
+
entity: "product",
|
|
53
|
+
fields: ["id", "status"],
|
|
54
|
+
filters: { id: data.id },
|
|
55
|
+
})
|
|
56
|
+
const status: string | undefined = products?.[0]?.status
|
|
57
|
+
if (!status) return
|
|
58
|
+
if (status === row?.metadata?.product_status) return
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
logger?.info?.(
|
|
62
|
+
`[faire] product ${data.id} status → "${status}" (was "${row?.metadata?.product_status ?? "unknown"}"); re-syncing product ${row.faire_product_token}`
|
|
63
|
+
)
|
|
64
|
+
await syncProductToFaireWorkflow(container).run({
|
|
65
|
+
input: { product_id: data.id },
|
|
66
|
+
})
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
logger?.warn?.(
|
|
69
|
+
`[faire] auto re-sync failed for product ${data.id}: ${err?.message || err}`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const config: SubscriberConfig = {
|
|
75
|
+
event: "product.updated",
|
|
76
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Pure mapping from a Faire order → the pieces createOrderWorkflow needs.
|
|
2
|
+
// Kept side-effect-free so it can be unit-tested without a container.
|
|
3
|
+
|
|
4
|
+
// Faire sends money as integer minor units in *_cents fields (2-decimal
|
|
5
|
+
// currencies). Medusa v2 order line-item unit_price and payment-collection
|
|
6
|
+
// amounts are decimal MAJOR units (e.g. 49.99, not 4999) — same contract the
|
|
7
|
+
// sibling Etsy plugin honours via etsyMoney(). Divide by 100 so orders are not
|
|
8
|
+
// created at 100× the real total.
|
|
9
|
+
export const faireMoney = (cents?: number): number => {
|
|
10
|
+
const n = Number(cents)
|
|
11
|
+
return Number.isFinite(n) ? n / 100 : 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const splitName = (name?: string): { first: string; last: string } => {
|
|
15
|
+
const parts = String(name || "").trim().split(/\s+/).filter(Boolean)
|
|
16
|
+
if (!parts.length) return { first: "Faire", last: "Buyer" }
|
|
17
|
+
if (parts.length === 1) return { first: parts[0], last: "" }
|
|
18
|
+
return { first: parts[0], last: parts.slice(1).join(" ") }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MappedFaireOrder = {
|
|
22
|
+
order_token: string
|
|
23
|
+
email: string
|
|
24
|
+
buyer_name: string | null
|
|
25
|
+
currency_code: string
|
|
26
|
+
total: number
|
|
27
|
+
shipping_address: Record<string, any>
|
|
28
|
+
items: Array<{
|
|
29
|
+
title: string
|
|
30
|
+
quantity: number
|
|
31
|
+
unit_price: number
|
|
32
|
+
metadata: Record<string, any>
|
|
33
|
+
}>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mapFaireOrderToOrder(order: any): MappedFaireOrder {
|
|
37
|
+
const orderToken = String(order?.order_token ?? order?.id ?? order?.token ?? "")
|
|
38
|
+
|
|
39
|
+
const address = order?.address ?? order?.shipping_address ?? {}
|
|
40
|
+
const currency = String(order?.currency ?? order?.currency_code ?? "usd").toLowerCase()
|
|
41
|
+
|
|
42
|
+
const buyerName =
|
|
43
|
+
order?.buyer_name ??
|
|
44
|
+
order?.customer_name ??
|
|
45
|
+
[address?.first_name, address?.last_name].filter(Boolean).join(" ")
|
|
46
|
+
const { first, last } = splitName(buyerName)
|
|
47
|
+
|
|
48
|
+
// Faire usually does NOT expose the retailer's email via API — fall back to a
|
|
49
|
+
// synthetic, non-deliverable address keyed by order token so the guest
|
|
50
|
+
// customer is stable and we never accidentally email a real inbox.
|
|
51
|
+
const email =
|
|
52
|
+
(typeof order?.buyer_email === "string" && order.buyer_email) ||
|
|
53
|
+
(typeof order?.email === "string" && order.email) ||
|
|
54
|
+
`faire+${orderToken}@marketplace.invalid`
|
|
55
|
+
|
|
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
|
+
})
|
|
74
|
+
|
|
75
|
+
const shipping_address = {
|
|
76
|
+
first_name: address?.first_name ?? first,
|
|
77
|
+
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 ?? "",
|
|
80
|
+
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,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
order_token: orderToken,
|
|
89
|
+
email,
|
|
90
|
+
buyer_name: buyerName || null,
|
|
91
|
+
currency_code: currency,
|
|
92
|
+
total: faireMoney(order?.total_cents ?? order?.grand_total_cents),
|
|
93
|
+
shipping_address,
|
|
94
|
+
items: mappedItems,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStep,
|
|
3
|
+
createWorkflow,
|
|
4
|
+
StepResponse,
|
|
5
|
+
WorkflowResponse,
|
|
6
|
+
WorkflowData,
|
|
7
|
+
} from "@medusajs/framework/workflows-sdk"
|
|
8
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
9
|
+
import {
|
|
10
|
+
createOrderWorkflow,
|
|
11
|
+
createOrderPaymentCollectionWorkflow,
|
|
12
|
+
markPaymentCollectionAsPaid,
|
|
13
|
+
} from "@medusajs/medusa/core-flows"
|
|
14
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
15
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
16
|
+
import { mapFaireOrderToOrder } from "./ingest-faire-order-support"
|
|
17
|
+
|
|
18
|
+
export const INGEST_FAIRE_ORDER = "faire-ingest-order"
|
|
19
|
+
|
|
20
|
+
export type IngestFaireOrderInput = {
|
|
21
|
+
order: any
|
|
22
|
+
order_token?: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Best-effort inventory decrement for a sold Faire order. Off by default — set
|
|
27
|
+
* FAIRE_DECREMENT_INVENTORY=true to enable. Resolves product → variant →
|
|
28
|
+
* inventory item from the product token (via sync records) and adjusts the
|
|
29
|
+
* level with the most stock down by the sold quantity.
|
|
30
|
+
*/
|
|
31
|
+
async function decrementInventory(
|
|
32
|
+
container: any,
|
|
33
|
+
items: Array<{ quantity: number; metadata: Record<string, any> }>,
|
|
34
|
+
service: FaireSyncService,
|
|
35
|
+
logger: any
|
|
36
|
+
) {
|
|
37
|
+
const query: any = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
38
|
+
const inventory: any = container.resolve(Modules.INVENTORY)
|
|
39
|
+
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const productToken = item.metadata?.faire_product_token
|
|
42
|
+
if (!productToken) continue
|
|
43
|
+
try {
|
|
44
|
+
const [records] = await service.listSyncRecords(
|
|
45
|
+
{ product_token: String(productToken) },
|
|
46
|
+
1,
|
|
47
|
+
0
|
|
48
|
+
)
|
|
49
|
+
const productId = (records as any[])?.[0]?.product_id
|
|
50
|
+
if (!productId) continue
|
|
51
|
+
|
|
52
|
+
const { data: products } = await query.graph({
|
|
53
|
+
entity: "product",
|
|
54
|
+
fields: [
|
|
55
|
+
"id",
|
|
56
|
+
"variants.inventory_items.inventory_item_id",
|
|
57
|
+
"variants.inventory_items.inventory.location_levels.location_id",
|
|
58
|
+
"variants.inventory_items.inventory.location_levels.stocked_quantity",
|
|
59
|
+
],
|
|
60
|
+
filters: { id: productId },
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const invItem = (products?.[0]?.variants || [])
|
|
64
|
+
.flatMap((v: any) => v.inventory_items || [])
|
|
65
|
+
.find((ii: any) => ii?.inventory_item_id)
|
|
66
|
+
const levels = invItem?.inventory?.location_levels || []
|
|
67
|
+
const level = [...levels].sort(
|
|
68
|
+
(a: any, b: any) => Number(b.stocked_quantity) - Number(a.stocked_quantity)
|
|
69
|
+
)[0]
|
|
70
|
+
if (!invItem?.inventory_item_id || !level?.location_id) continue
|
|
71
|
+
|
|
72
|
+
await inventory.adjustInventory(
|
|
73
|
+
invItem.inventory_item_id,
|
|
74
|
+
level.location_id,
|
|
75
|
+
-Math.abs(item.quantity)
|
|
76
|
+
)
|
|
77
|
+
logger?.info?.(
|
|
78
|
+
`[faire-order] decremented inventory ${invItem.inventory_item_id} by ${item.quantity}`
|
|
79
|
+
)
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
logger?.warn?.(
|
|
82
|
+
`[faire-order] inventory decrement skipped for product ${productToken}: ${err?.message}`
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ingestStep = createStep(
|
|
89
|
+
"faire-ingest-order-step",
|
|
90
|
+
async (
|
|
91
|
+
input: IngestFaireOrderInput,
|
|
92
|
+
{ container }
|
|
93
|
+
): Promise<StepResponse<{ order_id: string | null; skipped?: string }>> => {
|
|
94
|
+
const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
95
|
+
const query: any = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
96
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
97
|
+
|
|
98
|
+
const mapped = mapFaireOrderToOrder(input.order)
|
|
99
|
+
if (!mapped.order_token) {
|
|
100
|
+
return new StepResponse({ order_id: null, skipped: "no_order_token" })
|
|
101
|
+
}
|
|
102
|
+
if (!mapped.items.length) {
|
|
103
|
+
return new StepResponse({ order_id: null, skipped: "no_items" })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Idempotency — never create a second order for the same Faire order token.
|
|
107
|
+
const existing = await service
|
|
108
|
+
.listFaireOrders({ order_token: mapped.order_token } as any)
|
|
109
|
+
.catch(() => [])
|
|
110
|
+
const priorWithOrder = (existing as any[])?.find((e) => e.order_id)
|
|
111
|
+
if (priorWithOrder) {
|
|
112
|
+
return new StepResponse({ order_id: priorWithOrder.order_id, skipped: "duplicate" })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { data: regions } = await query.graph({
|
|
116
|
+
entity: "region",
|
|
117
|
+
fields: ["id", "currency_code"],
|
|
118
|
+
})
|
|
119
|
+
const region =
|
|
120
|
+
(regions as any[]).find(
|
|
121
|
+
(r) => String(r.currency_code).toLowerCase() === mapped.currency_code
|
|
122
|
+
) || (regions as any[])[0]
|
|
123
|
+
if (!region) {
|
|
124
|
+
throw new Error("No Medusa region configured to place the Faire order")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let salesChannelId: string | undefined
|
|
128
|
+
const { data: stores } = await query.graph({
|
|
129
|
+
entity: "store",
|
|
130
|
+
fields: ["id", "default_sales_channel_id"],
|
|
131
|
+
})
|
|
132
|
+
salesChannelId = (stores as any[])?.[0]?.default_sales_channel_id
|
|
133
|
+
if (!salesChannelId) {
|
|
134
|
+
const { data: channels } = await query.graph({
|
|
135
|
+
entity: "sales_channel",
|
|
136
|
+
fields: ["id"],
|
|
137
|
+
})
|
|
138
|
+
salesChannelId = (channels as any[])?.[0]?.id
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const orderMetadata = {
|
|
142
|
+
source: "faire",
|
|
143
|
+
faire_order_token: mapped.order_token,
|
|
144
|
+
faire_buyer_name: mapped.buyer_name,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { result: order } = await createOrderWorkflow(container).run({
|
|
148
|
+
input: {
|
|
149
|
+
region_id: region.id,
|
|
150
|
+
currency_code: mapped.currency_code,
|
|
151
|
+
sales_channel_id: salesChannelId,
|
|
152
|
+
email: mapped.email,
|
|
153
|
+
items: mapped.items as any,
|
|
154
|
+
shipping_address: mapped.shipping_address as any,
|
|
155
|
+
metadata: orderMetadata,
|
|
156
|
+
} as any,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const orderId = (order as any).id
|
|
160
|
+
|
|
161
|
+
// Payment: Faire already collected it → record the collection and mark paid
|
|
162
|
+
// so the Medusa order shows payment_status = captured.
|
|
163
|
+
try {
|
|
164
|
+
const { result: pcRes }: any = await createOrderPaymentCollectionWorkflow(
|
|
165
|
+
container
|
|
166
|
+
).run({ input: { order_id: orderId, amount: mapped.total as any } })
|
|
167
|
+
const paymentCollectionId = Array.isArray(pcRes) ? pcRes[0]?.id : pcRes?.id
|
|
168
|
+
if (paymentCollectionId) {
|
|
169
|
+
await markPaymentCollectionAsPaid(container).run({
|
|
170
|
+
input: {
|
|
171
|
+
payment_collection_id: paymentCollectionId,
|
|
172
|
+
order_id: orderId,
|
|
173
|
+
captured_by: "faire-webhook",
|
|
174
|
+
} as any,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
} catch (err: any) {
|
|
178
|
+
logger?.warn?.(
|
|
179
|
+
`[faire-order] order ${orderId} created but marking payment paid failed: ${err?.message}`
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (process.env.FAIRE_DECREMENT_INVENTORY === "true") {
|
|
184
|
+
await decrementInventory(container, mapped.items, service, logger)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await service
|
|
188
|
+
.createFaireOrders({
|
|
189
|
+
order_token: mapped.order_token,
|
|
190
|
+
order_id: orderId,
|
|
191
|
+
status: "created",
|
|
192
|
+
currency: mapped.currency_code,
|
|
193
|
+
total: String(mapped.total),
|
|
194
|
+
buyer_name: mapped.buyer_name,
|
|
195
|
+
raw: input.order,
|
|
196
|
+
} as any)
|
|
197
|
+
.catch(() => {})
|
|
198
|
+
|
|
199
|
+
logger?.info?.(
|
|
200
|
+
`[faire-order] created Medusa order ${orderId} from Faire order ${mapped.order_token}`
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return new StepResponse({ order_id: orderId })
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
export const ingestFaireOrderWorkflow = createWorkflow(
|
|
208
|
+
INGEST_FAIRE_ORDER,
|
|
209
|
+
(input: WorkflowData<IngestFaireOrderInput>) => {
|
|
210
|
+
return new WorkflowResponse(ingestStep(input))
|
|
211
|
+
}
|
|
212
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStep,
|
|
3
|
+
createWorkflow,
|
|
4
|
+
StepResponse,
|
|
5
|
+
WorkflowResponse,
|
|
6
|
+
WorkflowData,
|
|
7
|
+
transform,
|
|
8
|
+
} from "@medusajs/framework/workflows-sdk"
|
|
9
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
10
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
11
|
+
import { ingestFaireOrderWorkflow } from "./ingest-faire-order"
|
|
12
|
+
|
|
13
|
+
export const INGEST_FAIRE_ORDERS_BULK = "faire-ingest-orders-bulk"
|
|
14
|
+
|
|
15
|
+
export type IngestFaireOrdersBulkInput = {
|
|
16
|
+
// When omitted, pulls all available orders from Faire (paged).
|
|
17
|
+
limit?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Faire enforces a modest per-second request budget. Each order ingest fans
|
|
21
|
+
// out to several Medusa calls (create order + payment + optional inventory),
|
|
22
|
+
// so we pace *orders* conservatively and let the client's 429 retry-after
|
|
23
|
+
// backoff absorb bursts.
|
|
24
|
+
const BASE_DELAY_MS = 600
|
|
25
|
+
const MAX_DELAY_MS = 10_000
|
|
26
|
+
const PROGRESS_EVERY = 5
|
|
27
|
+
const PAGE_SIZE = 50
|
|
28
|
+
|
|
29
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
30
|
+
|
|
31
|
+
// ── Step 1 (inline): open the batch so the HTTP caller gets an id to poll ──
|
|
32
|
+
|
|
33
|
+
const openBatchStep = createStep(
|
|
34
|
+
"faire-ingest-orders-open-batch-step",
|
|
35
|
+
async (
|
|
36
|
+
_input: IngestFaireOrdersBulkInput,
|
|
37
|
+
{ container, context }
|
|
38
|
+
): Promise<StepResponse<{ batch_id: string }>> => {
|
|
39
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
40
|
+
|
|
41
|
+
const account = await service.getActiveAccount()
|
|
42
|
+
if (!account) {
|
|
43
|
+
throw new Error("Faire account is not connected")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const batch = await service.createFaireSyncBatches({
|
|
47
|
+
transaction_id: context.transactionId,
|
|
48
|
+
status: "processing",
|
|
49
|
+
total_products: 0, // populated as we discover orders (generic counter)
|
|
50
|
+
synced_count: 0,
|
|
51
|
+
failed_count: 0,
|
|
52
|
+
error_log: {},
|
|
53
|
+
started_at: new Date(),
|
|
54
|
+
} as any)
|
|
55
|
+
|
|
56
|
+
return new StepResponse({ batch_id: (batch as any).id })
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// ── Step 2 (async, background): page through Faire orders + ingest each ─────
|
|
61
|
+
|
|
62
|
+
const processBatchStep = createStep(
|
|
63
|
+
"faire-ingest-orders-process-batch-step",
|
|
64
|
+
async (
|
|
65
|
+
input: { batch_id: string; limit?: number },
|
|
66
|
+
{ container }
|
|
67
|
+
): Promise<StepResponse<{ synced: number; failed: number }>> => {
|
|
68
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
69
|
+
const account = await service.ensureFreshToken()
|
|
70
|
+
const client = service.getClient()
|
|
71
|
+
|
|
72
|
+
let synced = 0
|
|
73
|
+
let failed = 0
|
|
74
|
+
let total = 0
|
|
75
|
+
let delay = BASE_DELAY_MS
|
|
76
|
+
const errors: Record<string, string> = {}
|
|
77
|
+
|
|
78
|
+
const persistProgress = async () => {
|
|
79
|
+
await service
|
|
80
|
+
.updateFaireSyncBatches({
|
|
81
|
+
id: input.batch_id,
|
|
82
|
+
total_products: total,
|
|
83
|
+
synced_count: synced,
|
|
84
|
+
failed_count: failed,
|
|
85
|
+
error_log: errors,
|
|
86
|
+
} as any)
|
|
87
|
+
.catch(() => {})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
let page = 1
|
|
92
|
+
let stop = false
|
|
93
|
+
while (!stop) {
|
|
94
|
+
const { results } = await client.listOrders(account.access_token, {
|
|
95
|
+
limit: PAGE_SIZE,
|
|
96
|
+
page,
|
|
97
|
+
})
|
|
98
|
+
if (!results.length) break
|
|
99
|
+
total += results.length
|
|
100
|
+
|
|
101
|
+
for (const order of results) {
|
|
102
|
+
const token = order.order_token
|
|
103
|
+
try {
|
|
104
|
+
await ingestFaireOrderWorkflow(container).run({
|
|
105
|
+
input: { order: order.raw, order_token: token },
|
|
106
|
+
})
|
|
107
|
+
// Skip === duplicate is still a successful no-op.
|
|
108
|
+
synced++
|
|
109
|
+
delay = Math.max(BASE_DELAY_MS, Math.floor(delay / 2))
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
failed++
|
|
112
|
+
errors[token] = err?.message || "Unknown error"
|
|
113
|
+
if (/429|rate limit/i.test(errors[token])) {
|
|
114
|
+
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ((synced + failed) % PROGRESS_EVERY === 0) {
|
|
119
|
+
await persistProgress()
|
|
120
|
+
}
|
|
121
|
+
await sleep(delay)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Honor an explicit limit on total orders processed.
|
|
125
|
+
if (input.limit != null && total >= input.limit) {
|
|
126
|
+
stop = true
|
|
127
|
+
}
|
|
128
|
+
// Stop if the last page was short (no more pages).
|
|
129
|
+
if (results.length < PAGE_SIZE) {
|
|
130
|
+
stop = true
|
|
131
|
+
}
|
|
132
|
+
page++
|
|
133
|
+
}
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
errors["__fetch__"] = err?.message || "Failed to list Faire orders"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await service.updateFaireSyncBatches({
|
|
139
|
+
id: input.batch_id,
|
|
140
|
+
total_products: total,
|
|
141
|
+
status: failed === total && total > 0 ? "failed" : "completed",
|
|
142
|
+
synced_count: synced,
|
|
143
|
+
failed_count: failed,
|
|
144
|
+
error_log: errors,
|
|
145
|
+
completed_at: new Date(),
|
|
146
|
+
} as any)
|
|
147
|
+
|
|
148
|
+
return new StepResponse({ synced, failed })
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
export const ingestFaireOrdersBulkWorkflow = createWorkflow(
|
|
153
|
+
INGEST_FAIRE_ORDERS_BULK,
|
|
154
|
+
(input: WorkflowData<IngestFaireOrdersBulkInput>) => {
|
|
155
|
+
const opened = openBatchStep(input)
|
|
156
|
+
|
|
157
|
+
// Long-running: the HTTP request returns as soon as the batch is opened;
|
|
158
|
+
// the actual per-order ingest runs in the background workflow engine.
|
|
159
|
+
processBatchStep(
|
|
160
|
+
transform({ input, opened }, (data) => ({
|
|
161
|
+
batch_id: data.opened.batch_id,
|
|
162
|
+
limit: data.input.limit,
|
|
163
|
+
}))
|
|
164
|
+
).config({ async: true, backgroundExecution: true })
|
|
165
|
+
|
|
166
|
+
return new WorkflowResponse({ batch_id: opened.batch_id })
|
|
167
|
+
}
|
|
168
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStep,
|
|
3
|
+
createWorkflow,
|
|
4
|
+
StepResponse,
|
|
5
|
+
WorkflowResponse,
|
|
6
|
+
} from "@medusajs/framework/workflows-sdk"
|
|
7
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
8
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
9
|
+
|
|
10
|
+
export const REFRESH_FAIRE_TOKEN = "faire-refresh-token"
|
|
11
|
+
|
|
12
|
+
const refreshFaireTokenStep = createStep(
|
|
13
|
+
"faire-refresh-token-step",
|
|
14
|
+
async (_, { container }): Promise<StepResponse<{ refreshed: boolean }>> => {
|
|
15
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
16
|
+
const account = await service.getActiveAccount()
|
|
17
|
+
if (!account) {
|
|
18
|
+
return new StepResponse({ refreshed: false })
|
|
19
|
+
}
|
|
20
|
+
await service.ensureFreshToken(account)
|
|
21
|
+
return new StepResponse({ refreshed: true })
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const refreshFaireTokenWorkflow = createWorkflow(REFRESH_FAIRE_TOKEN, () => {
|
|
26
|
+
const result = refreshFaireTokenStep()
|
|
27
|
+
return new WorkflowResponse(result)
|
|
28
|
+
})
|