@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.
Files changed (104) hide show
  1. package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
  2. package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
  3. package/.medusa/server/src/admin/index.js +960 -0
  4. package/.medusa/server/src/admin/index.mjs +959 -0
  5. package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
  6. package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
  7. package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
  8. package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
  9. package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
  10. package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
  11. package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
  12. package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
  13. package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
  14. package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
  15. package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
  16. package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
  17. package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
  18. package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
  19. package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
  20. package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
  21. package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
  22. package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
  23. package/.medusa/server/src/lib/faire-client.js +321 -0
  24. package/.medusa/server/src/lib/types.js +10 -0
  25. package/.medusa/server/src/lib/webhook.js +58 -0
  26. package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
  27. package/.medusa/server/src/modules/faire-sync/index.js +15 -0
  28. package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
  29. package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
  30. package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
  31. package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
  32. package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
  33. package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
  34. package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
  35. package/.medusa/server/src/modules/faire-sync/service.js +222 -0
  36. package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
  37. package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
  38. package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
  39. package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
  40. package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
  41. package/.medusa/server/src/workflows/index.js +21 -0
  42. package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
  43. package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
  44. package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
  45. package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
  46. package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
  47. package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
  48. package/LICENSE +21 -0
  49. package/README.md +180 -0
  50. package/package.json +84 -0
  51. package/scripts/postbuild.js +36 -0
  52. package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
  53. package/src/__tests__/webhook.spec.ts +81 -0
  54. package/src/admin/lib/api.ts +88 -0
  55. package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
  56. package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
  57. package/src/admin/routes/settings/faire/page.tsx +445 -0
  58. package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
  59. package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
  60. package/src/admin/widgets/faire-product-widget.tsx +180 -0
  61. package/src/api/admin/faire/auth/authorize/route.ts +10 -0
  62. package/src/api/admin/faire/auth/callback/route.ts +29 -0
  63. package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
  64. package/src/api/admin/faire/brand/route.ts +12 -0
  65. package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
  66. package/src/api/admin/faire/ingest/orders/route.ts +29 -0
  67. package/src/api/admin/faire/products/route.ts +14 -0
  68. package/src/api/admin/faire/settings/route.ts +17 -0
  69. package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
  70. package/src/api/admin/faire/status/route.ts +47 -0
  71. package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
  72. package/src/api/admin/faire/sync/bulk/route.ts +27 -0
  73. package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
  74. package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
  75. package/src/api/admin/faire/syncs/route.ts +17 -0
  76. package/src/api/webhooks/faire/route.ts +133 -0
  77. package/src/jobs/pull-faire-orders.ts +30 -0
  78. package/src/jobs/refresh-faire-token.ts +23 -0
  79. package/src/lib/faire-client.ts +436 -0
  80. package/src/lib/types.ts +117 -0
  81. package/src/lib/webhook.ts +85 -0
  82. package/src/links/product-faire-sync-link.ts +56 -0
  83. package/src/modules/faire-sync/index.ts +11 -0
  84. package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
  85. package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
  86. package/src/modules/faire-sync/models/faire-order.ts +20 -0
  87. package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
  88. package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
  89. package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
  90. package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
  91. package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
  92. package/src/modules/faire-sync/service.ts +252 -0
  93. package/src/subscribers/faire-inventory-sync.ts +97 -0
  94. package/src/subscribers/faire-listing-events.ts +48 -0
  95. package/src/subscribers/faire-order-ingest.ts +61 -0
  96. package/src/subscribers/faire-order-lifecycle.ts +81 -0
  97. package/src/subscribers/faire-product-status-sync.ts +76 -0
  98. package/src/workflows/index.ts +4 -0
  99. package/src/workflows/ingest-faire-order-support.ts +96 -0
  100. package/src/workflows/ingest-faire-order.ts +212 -0
  101. package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
  102. package/src/workflows/refresh-faire-token.ts +28 -0
  103. package/src/workflows/sync-product-to-faire.ts +375 -0
  104. 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,4 @@
1
+ export * from "./sync-product-to-faire"
2
+ export * from "./sync-products-to-faire"
3
+ export * from "./refresh-faire-token"
4
+ export * from "./ingest-faire-orders-bulk"
@@ -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
+ })