@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,43 @@
1
+ import { Migration } from "@medusajs/framework/mikro-orm/migrations";
2
+
3
+ export class Migration20260707211346 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table if exists "faire_webhook_event" drop constraint if exists "faire_webhook_event_webhook_id_unique";`);
7
+ this.addSql(`alter table if exists "faire_order" drop constraint if exists "faire_order_order_token_unique";`);
8
+ this.addSql(`create table if not exists "faire_order" ("id" text not null, "order_token" text not null, "order_id" text null, "status" text not null default 'created', "currency" text null, "total" text null, "buyer_name" text null, "raw" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_order_pkey" primary key ("id"));`);
9
+ this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_faire_order_order_token_unique" ON "faire_order" ("order_token") WHERE deleted_at IS NULL;`);
10
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_order_deleted_at" ON "faire_order" ("deleted_at") WHERE deleted_at IS NULL;`);
11
+
12
+ this.addSql(`create table if not exists "faire_sync_account" ("id" text not null, "brand_id" text not null, "brand_name" text not null, "currency" text null, "country" text null, "access_token" text not null, "refresh_token" text null, "token_expires_at" timestamptz null, "brand_info" jsonb null, "is_active" boolean not null default true, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_account_pkey" primary key ("id"));`);
13
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_account_deleted_at" ON "faire_sync_account" ("deleted_at") WHERE deleted_at IS NULL;`);
14
+
15
+ this.addSql(`create table if not exists "faire_sync_batch" ("id" text not null, "transaction_id" text null, "status" text check ("status" in ('pending', 'processing', 'completed', 'failed')) not null default 'pending', "total_products" integer not null default 0, "synced_count" integer not null default 0, "failed_count" integer not null default 0, "error_log" jsonb null, "started_at" timestamptz null, "completed_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_batch_pkey" primary key ("id"));`);
16
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_batch_deleted_at" ON "faire_sync_batch" ("deleted_at") WHERE deleted_at IS NULL;`);
17
+
18
+ this.addSql(`create table if not exists "faire_sync_record" ("id" text not null, "product_id" text not null, "account_id" text not null, "product_token" text null, "product_url" text null, "product_state" text null, "action" text check ("action" in ('create', 'update', 'delete')) not null default 'create', "status" text check ("status" in ('pending', 'syncing', 'success', 'failed', 'draft')) not null default 'pending', "published" boolean not null default false, "error_message" text null, "warnings" jsonb null, "metadata" jsonb null, "synced_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_record_pkey" primary key ("id"));`);
19
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_record_deleted_at" ON "faire_sync_record" ("deleted_at") WHERE deleted_at IS NULL;`);
20
+
21
+ this.addSql(`create table if not exists "faire_sync_settings" ("id" text not null, "account_id" text null, "default_brand_id" text null, "default_wholesale_markup_percent" integer null, "default_min_order_quantity" integer not null default 1, "default_lead_time_days" integer null, "default_shipping_policy_id" text null, "default_category" text null, "auto_publish" boolean not null default false, "follow_product_status" boolean not null default true, "pending_oauth" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_settings_pkey" primary key ("id"));`);
22
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_settings_deleted_at" ON "faire_sync_settings" ("deleted_at") WHERE deleted_at IS NULL;`);
23
+
24
+ this.addSql(`create table if not exists "faire_webhook_event" ("id" text not null, "webhook_id" text not null, "event_type" text not null, "brand_id" text null, "resource_url" text null, "payload" jsonb null, "resource" jsonb null, "processed" boolean not null default false, "error" text null, "received_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_webhook_event_pkey" primary key ("id"));`);
25
+ this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_faire_webhook_event_webhook_id_unique" ON "faire_webhook_event" ("webhook_id") WHERE deleted_at IS NULL;`);
26
+ this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_webhook_event_deleted_at" ON "faire_webhook_event" ("deleted_at") WHERE deleted_at IS NULL;`);
27
+ }
28
+
29
+ override async down(): Promise<void> {
30
+ this.addSql(`drop table if exists "faire_order" cascade;`);
31
+
32
+ this.addSql(`drop table if exists "faire_sync_account" cascade;`);
33
+
34
+ this.addSql(`drop table if exists "faire_sync_batch" cascade;`);
35
+
36
+ this.addSql(`drop table if exists "faire_sync_record" cascade;`);
37
+
38
+ this.addSql(`drop table if exists "faire_sync_settings" cascade;`);
39
+
40
+ this.addSql(`drop table if exists "faire_webhook_event" cascade;`);
41
+ }
42
+
43
+ }
@@ -0,0 +1,20 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ /**
4
+ * Maps a Faire order to the Medusa order created from it. `order_token` is
5
+ * unique — the idempotency key so a Faire webhook retry (or a later
6
+ * order.canceled/shipped event for the same order) never re-creates the
7
+ * Medusa order.
8
+ */
9
+ const FaireOrder = model.define("faire_order", {
10
+ id: model.id().primaryKey(),
11
+ order_token: model.text().unique(),
12
+ order_id: model.text().nullable(), // Medusa order id
13
+ status: model.text().default("created"),
14
+ currency: model.text().nullable(),
15
+ total: model.text().nullable(), // decimal string (cents as integer string)
16
+ buyer_name: model.text().nullable(),
17
+ raw: model.json().nullable(),
18
+ })
19
+
20
+ export default FaireOrder
@@ -0,0 +1,16 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ const FaireSyncAccount = model.define("faire_sync_account", {
4
+ id: model.id().primaryKey(),
5
+ brand_id: model.text(),
6
+ brand_name: model.text(),
7
+ currency: model.text().nullable(),
8
+ country: model.text().nullable(),
9
+ access_token: model.text(),
10
+ refresh_token: model.text().nullable(),
11
+ token_expires_at: model.dateTime().nullable(),
12
+ brand_info: model.json().nullable(),
13
+ is_active: model.boolean().default(true),
14
+ })
15
+
16
+ export default FaireSyncAccount
@@ -0,0 +1,17 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ const FaireSyncBatch = model.define("faire_sync_batch", {
4
+ id: model.id().primaryKey(),
5
+ transaction_id: model.text().nullable(),
6
+ status: model
7
+ .enum(["pending", "processing", "completed", "failed"])
8
+ .default("pending"),
9
+ total_products: model.number().default(0),
10
+ synced_count: model.number().default(0),
11
+ failed_count: model.number().default(0),
12
+ error_log: model.json().nullable(),
13
+ started_at: model.dateTime().nullable(),
14
+ completed_at: model.dateTime().nullable(),
15
+ })
16
+
17
+ export default FaireSyncBatch
@@ -0,0 +1,21 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ const FaireSyncRecord = model.define("faire_sync_record", {
4
+ id: model.id().primaryKey(),
5
+ product_id: model.text(),
6
+ account_id: model.text(),
7
+ product_token: model.text().nullable(),
8
+ product_url: model.text().nullable(),
9
+ product_state: model.text().nullable(),
10
+ action: model.enum(["create", "update", "delete"]).default("create"),
11
+ status: model
12
+ .enum(["pending", "syncing", "success", "failed", "draft"])
13
+ .default("pending"),
14
+ published: model.boolean().default(false),
15
+ error_message: model.text().nullable(),
16
+ warnings: model.json().nullable(),
17
+ metadata: model.json().nullable(),
18
+ synced_at: model.dateTime().nullable(),
19
+ })
20
+
21
+ export default FaireSyncRecord
@@ -0,0 +1,25 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ /**
4
+ * Singleton settings row. Holds publish-readiness defaults plus the in-flight
5
+ * OAuth `state` (keyed by it) so the callback can verify the round-trip.
6
+ *
7
+ * Faire-flavored readiness: connection, brand id, wholesale pricing strategy,
8
+ * and shipping policy. Missing fields sync products as drafts rather than
9
+ * publishing them.
10
+ */
11
+ const FaireSyncSettings = model.define("faire_sync_settings", {
12
+ id: model.id().primaryKey(),
13
+ account_id: model.text().nullable(),
14
+ default_brand_id: model.text().nullable(),
15
+ default_wholesale_markup_percent: model.number().nullable(),
16
+ default_min_order_quantity: model.number().default(1),
17
+ default_lead_time_days: model.number().nullable(),
18
+ default_shipping_policy_id: model.text().nullable(),
19
+ default_category: model.text().nullable(),
20
+ auto_publish: model.boolean().default(false),
21
+ follow_product_status: model.boolean().default(true),
22
+ pending_oauth: model.json().nullable(),
23
+ })
24
+
25
+ export default FaireSyncSettings
@@ -0,0 +1,20 @@
1
+ import { model } from "@medusajs/framework/utils"
2
+
3
+ /**
4
+ * Durable record of an inbound Faire webhook delivery. `webhook_id` is the
5
+ * delivery id Faire sends (unique) so retries are idempotent.
6
+ */
7
+ const FaireWebhookEvent = model.define("faire_webhook_event", {
8
+ id: model.id().primaryKey(),
9
+ webhook_id: model.text().unique(),
10
+ event_type: model.text(),
11
+ brand_id: model.text().nullable(),
12
+ resource_url: model.text().nullable(),
13
+ payload: model.json().nullable(),
14
+ resource: model.json().nullable(),
15
+ processed: model.boolean().default(false),
16
+ error: model.text().nullable(),
17
+ received_at: model.dateTime().nullable(),
18
+ })
19
+
20
+ export default FaireWebhookEvent
@@ -0,0 +1,252 @@
1
+ import { MedusaError, MedusaService } from "@medusajs/framework/utils"
2
+ import FaireSyncAccount from "./models/faire-sync-account"
3
+ import FaireSyncRecord from "./models/faire-sync-record"
4
+ import FaireSyncSettings from "./models/faire-sync-settings"
5
+ import FaireSyncBatch from "./models/faire-sync-batch"
6
+ import FaireWebhookEvent from "./models/faire-webhook-event"
7
+ import FaireOrder from "./models/faire-order"
8
+ import { FaireClient } from "../../lib/faire-client"
9
+ import { FairePluginOptions, BrandInfo, TokenData } from "../../lib/types"
10
+
11
+ type ModuleOptions = FairePluginOptions & Record<string, any>
12
+
13
+ function readOption(options: any, key: string): string | undefined {
14
+ try {
15
+ const value = options?.[key]
16
+ return typeof value === "string" ? value : undefined
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
21
+
22
+ function toMedusaError(err: any): MedusaError {
23
+ if (err instanceof MedusaError) return err
24
+
25
+ const status: number | undefined = err?.status
26
+ const body: any = err?.body
27
+ const message: string =
28
+ body?.error_description || body?.error || err?.message || "Faire request failed"
29
+
30
+ if (status === 400 && /invalid_grant|invalid code|already used/i.test(message)) {
31
+ return new MedusaError(
32
+ MedusaError.Types.INVALID_DATA,
33
+ "Faire authorization expired or was already used. Please reconnect."
34
+ )
35
+ }
36
+ if (/brand/i.test(message) && /(no|not|own)/i.test(message)) {
37
+ return new MedusaError(MedusaError.Types.NOT_FOUND, message)
38
+ }
39
+ if (status === 401 || status === 403) {
40
+ return new MedusaError(MedusaError.Types.NOT_ALLOWED, message)
41
+ }
42
+ if (status === 404) {
43
+ return new MedusaError(MedusaError.Types.NOT_FOUND, message)
44
+ }
45
+ if (status === 400 || status === 422) {
46
+ return new MedusaError(MedusaError.Types.INVALID_DATA, message)
47
+ }
48
+ return new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message)
49
+ }
50
+
51
+ function resolveFaireOptions(options?: ModuleOptions): ModuleOptions {
52
+ return {
53
+ clientId: process.env.FAIRE_CLIENT_ID ?? readOption(options, "clientId") ?? "",
54
+ clientSecret:
55
+ process.env.FAIRE_CLIENT_SECRET ?? readOption(options, "clientSecret") ?? "",
56
+ redirectUri:
57
+ process.env.FAIRE_REDIRECT_URI ??
58
+ readOption(options, "redirectUri") ??
59
+ "http://localhost:9000/app/settings/oauth/faire/callback",
60
+ apiBase: process.env.FAIRE_API_BASE ?? readOption(options, "apiBase"),
61
+ authUrl: process.env.FAIRE_AUTH_URL ?? readOption(options, "authUrl"),
62
+ tokenUrl: process.env.FAIRE_TOKEN_URL ?? readOption(options, "tokenUrl"),
63
+ scope: process.env.FAIRE_SCOPE ?? readOption(options, "scope") ?? "",
64
+ webhookSecret:
65
+ process.env.FAIRE_WEBHOOK_SECRET ?? readOption(options, "webhookSecret") ?? "",
66
+ } as ModuleOptions
67
+ }
68
+
69
+ class FaireSyncService extends MedusaService({
70
+ FaireSyncAccount,
71
+ FaireSyncRecord,
72
+ FaireSyncSettings,
73
+ FaireSyncBatch,
74
+ FaireWebhookEvent,
75
+ FaireOrder,
76
+ }) {
77
+ protected options_: ModuleOptions
78
+ protected client_: FaireClient | null = null
79
+
80
+ constructor(container: any, options?: ModuleOptions) {
81
+ super(...arguments)
82
+ this.options_ = resolveFaireOptions(options)
83
+ }
84
+
85
+ getClient(): FaireClient {
86
+ if (!this.client_) {
87
+ this.client_ = new FaireClient(this.options_)
88
+ }
89
+ return this.client_
90
+ }
91
+
92
+ getOptions(): ModuleOptions {
93
+ return this.options_
94
+ }
95
+
96
+ async getActiveAccount() {
97
+ const [account] = await this.listFaireSyncAccounts({
98
+ is_active: true,
99
+ } as any)
100
+ return account ?? null
101
+ }
102
+
103
+ async saveAccount(token: TokenData, brand: BrandInfo) {
104
+ const existing = await this.getActiveAccount()
105
+ const expiresAt =
106
+ token.expires_in != null
107
+ ? new Date(token.retrieved_at + token.expires_in * 1000)
108
+ : null
109
+ const payload = {
110
+ brand_id: brand.brand_id,
111
+ brand_name: brand.brand_name,
112
+ currency: brand.currency ?? null,
113
+ country: brand.country ?? null,
114
+ access_token: token.access_token,
115
+ refresh_token: token.refresh_token ?? null,
116
+ token_expires_at: expiresAt,
117
+ brand_info: brand.raw,
118
+ is_active: true,
119
+ }
120
+ if (existing) {
121
+ return this.updateFaireSyncAccounts({
122
+ id: existing.id,
123
+ ...payload,
124
+ } as any)
125
+ }
126
+ return this.createFaireSyncAccounts(payload as any)
127
+ }
128
+
129
+ async disconnect() {
130
+ const account = await this.getActiveAccount()
131
+ if (!account) return
132
+ await this.updateFaireSyncAccounts({
133
+ id: account.id,
134
+ is_active: false,
135
+ access_token: "",
136
+ refresh_token: null,
137
+ } as any)
138
+ }
139
+
140
+ async ensureFreshToken(account?: any) {
141
+ const acct = account ?? (await this.getActiveAccount())
142
+ if (!acct) {
143
+ throw new MedusaError(
144
+ MedusaError.Types.NOT_FOUND,
145
+ "Faire account is not connected"
146
+ )
147
+ }
148
+ if (!acct.token_expires_at) {
149
+ return acct
150
+ }
151
+ const now = Date.now()
152
+ const expiresAt = new Date(acct.token_expires_at).getTime()
153
+ const fiveMinutes = 5 * 60 * 1000
154
+ if (expiresAt - now > fiveMinutes) {
155
+ return acct
156
+ }
157
+ if (!acct.refresh_token) {
158
+ throw new MedusaError(
159
+ MedusaError.Types.NOT_ALLOWED,
160
+ "Faire access token has expired. Please reconnect your Faire account."
161
+ )
162
+ }
163
+ try {
164
+ const client = this.getClient()
165
+ const token = await client.refreshAccessToken(acct.refresh_token)
166
+ return this.updateFaireSyncAccounts({
167
+ id: acct.id,
168
+ access_token: token.access_token,
169
+ refresh_token: token.refresh_token ?? acct.refresh_token,
170
+ token_expires_at:
171
+ token.expires_in != null
172
+ ? new Date(token.retrieved_at + token.expires_in * 1000)
173
+ : null,
174
+ } as any)
175
+ } catch (err) {
176
+ throw toMedusaError(err)
177
+ }
178
+ }
179
+
180
+ async getSettings() {
181
+ const [settings] = await this.listFaireSyncSettings({}, { take: 1 } as any)
182
+ if (settings) return settings
183
+ return this.createFaireSyncSettings({} as any)
184
+ }
185
+
186
+ async updateSettings(data: any) {
187
+ const settings = await this.getSettings()
188
+ return this.updateFaireSyncSettings({ id: settings.id, ...data } as any)
189
+ }
190
+
191
+ async startOAuth(): Promise<{ authorization_url: string; state: string }> {
192
+ try {
193
+ const state = crypto.randomUUID()
194
+ await this.updateSettings({
195
+ pending_oauth: {
196
+ state,
197
+ created_at: Date.now(),
198
+ },
199
+ })
200
+ const client = this.getClient()
201
+ return {
202
+ authorization_url: client.getAuthorizationUrl(state),
203
+ state,
204
+ }
205
+ } catch (err) {
206
+ throw toMedusaError(err)
207
+ }
208
+ }
209
+
210
+ async completeOAuth(code: string, state: string) {
211
+ const settings = await this.getSettings()
212
+ const pending: any = settings.pending_oauth
213
+ if (!pending || pending.state !== state) {
214
+ throw new MedusaError(
215
+ MedusaError.Types.INVALID_DATA,
216
+ "Invalid or expired OAuth state. Please start the Faire connection again."
217
+ )
218
+ }
219
+ try {
220
+ const client = this.getClient()
221
+ const token = await client.exchangeCodeForToken(code)
222
+ const brand = await client.getBrand(token.access_token)
223
+ const account = await this.saveAccount(token, brand)
224
+ await this.updateSettings({
225
+ account_id: account.id,
226
+ default_brand_id: brand.brand_id,
227
+ pending_oauth: null,
228
+ })
229
+ return { account, brand }
230
+ } catch (err) {
231
+ throw toMedusaError(err)
232
+ }
233
+ }
234
+
235
+ async createSyncRecord(data: any) {
236
+ return this.createFaireSyncRecords(data as any)
237
+ }
238
+
239
+ async updateSyncRecord(id: string, data: any) {
240
+ return this.updateFaireSyncRecords({ id, ...data } as any)
241
+ }
242
+
243
+ async listSyncRecords(filters: any = {}, take = 20, skip = 0) {
244
+ return this.listAndCountFaireSyncRecords(filters, {
245
+ take,
246
+ skip,
247
+ order: { created_at: "DESC" },
248
+ } as any)
249
+ }
250
+ }
251
+
252
+ export default FaireSyncService
@@ -0,0 +1,97 @@
1
+ import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
2
+ import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
3
+ import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
4
+ import FaireSyncService from "../modules/faire-sync/service"
5
+
6
+ /**
7
+ * React to inventory updates Faire pushes (e.g. a retailer placed an order and
8
+ * Faire decremented stock). We mirror the remote count back onto the linked
9
+ * Medusa variant's inventory so the two systems stay consistent. Best-effort —
10
+ * never throws.
11
+ */
12
+ export default async function faireInventorySyncHandler({
13
+ event: { data },
14
+ container,
15
+ }: SubscriberArgs<{ resource?: any; resource_url?: string }>) {
16
+ const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
17
+ const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
18
+
19
+ let payload: any = data?.resource
20
+ if (!payload && data?.resource_url) {
21
+ try {
22
+ const account = await service.ensureFreshToken()
23
+ payload = await service
24
+ .getClient()
25
+ .fetchResource((account as any).access_token, data.resource_url)
26
+ } catch {
27
+ return
28
+ }
29
+ }
30
+ if (!payload) return
31
+
32
+ const levels: any[] = Array.isArray(payload?.inventory)
33
+ ? payload.inventory
34
+ : Array.isArray(payload)
35
+ ? payload
36
+ : payload?.items
37
+ ? payload.items
38
+ : []
39
+ if (!levels.length) return
40
+
41
+ try {
42
+ const query: any = container.resolve(ContainerRegistrationKeys.QUERY)
43
+ const inventory: any = container.resolve(Modules.INVENTORY)
44
+
45
+ for (const lvl of levels) {
46
+ const sku = String(lvl.sku ?? "")
47
+ const count = Number(lvl.current_count ?? lvl.inventory_count)
48
+ if (!sku || !Number.isFinite(count)) continue
49
+
50
+ // Find the linked Medusa product via sync record matching this SKU's
51
+ // product token, then resolve its inventory item and set the level.
52
+ const productToken = lvl.product_token ? String(lvl.product_token) : null
53
+ if (!productToken) continue
54
+ const [records] = await service.listSyncRecords({ product_token: productToken }, 1, 0)
55
+ const productId = (records as any[])?.[0]?.product_id
56
+ if (!productId) continue
57
+
58
+ const { data: products } = await query.graph({
59
+ entity: "product",
60
+ fields: [
61
+ "id",
62
+ "variants.sku",
63
+ "variants.inventory_items.inventory_item_id",
64
+ "variants.inventory_items.inventory.location_levels.location_id",
65
+ "variants.inventory_items.inventory.location_levels.stocked_quantity",
66
+ ],
67
+ filters: { id: productId },
68
+ })
69
+
70
+ const variant = (products?.[0]?.variants || []).find((v: any) => v.sku === sku)
71
+ const invItem = (variant?.inventory_items || [])[0]
72
+ const levels2 = invItem?.inventory?.location_levels || []
73
+ const level = [...levels2].sort(
74
+ (a: any, b: any) => Number(b.stocked_quantity) - Number(a.stocked_quantity)
75
+ )[0]
76
+ if (!invItem?.inventory_item_id || !level?.location_id) continue
77
+
78
+ const delta = count - Number(level.stocked_quantity || 0)
79
+ if (delta !== 0) {
80
+ await inventory.adjustInventory(
81
+ invItem.inventory_item_id,
82
+ level.location_id,
83
+ delta
84
+ )
85
+ logger?.info?.(
86
+ `[faire-inventory] ${sku} adjusted by ${delta} → ${count}`
87
+ )
88
+ }
89
+ }
90
+ } catch (err: any) {
91
+ logger?.warn?.(`[faire-inventory] sync failed: ${err?.message}`)
92
+ }
93
+ }
94
+
95
+ export const config: SubscriberConfig = {
96
+ event: "faire.inventory_updated",
97
+ }
@@ -0,0 +1,48 @@
1
+ import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
2
+ import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
3
+
4
+ /**
5
+ * Surface notable Faire product/listing changes to the admin feed. Faire emits
6
+ * outbound webhooks for product updates; the webhook receiver re-emits internal
7
+ * `faire.product.*` / `faire.listing.*` events that this subscriber turns into
8
+ * admin feed notifications. Extend here to auto-restock or re-list.
9
+ */
10
+ const LABELS: Record<string, string> = {
11
+ "faire.product_updated": "was updated on Faire",
12
+ "faire.product_sold_out": "sold out on Faire",
13
+ "faire.product_deactivated": "was deactivated on Faire",
14
+ "faire.listing.deleted": "was removed on Faire",
15
+ }
16
+
17
+ export default async function faireListingEventsHandler({
18
+ event,
19
+ container,
20
+ }: SubscriberArgs<{ product_token?: string; resource?: any }>) {
21
+ const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
22
+ const label = LABELS[event.name] || "changed on Faire"
23
+ const token = event.data?.product_token
24
+
25
+ try {
26
+ const notification: any = container.resolve(Modules.NOTIFICATION)
27
+ await notification.createNotifications({
28
+ to: "",
29
+ channel: "feed",
30
+ template: "admin-ui",
31
+ data: {
32
+ title: "Faire product update",
33
+ description: `Faire product ${token ?? ""} ${label}.`,
34
+ },
35
+ })
36
+ } catch (err: any) {
37
+ logger?.warn?.(`[faire-listing] notify failed for ${event.name}: ${err?.message}`)
38
+ }
39
+ }
40
+
41
+ export const config: SubscriberConfig = {
42
+ event: [
43
+ "faire.product_updated",
44
+ "faire.product_sold_out",
45
+ "faire.product_deactivated",
46
+ "faire.listing.deleted",
47
+ ],
48
+ }
@@ -0,0 +1,61 @@
1
+ import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
2
+ import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
3
+ import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
4
+ import FaireSyncService from "../modules/faire-sync/service"
5
+ import { ingestFaireOrderWorkflow } from "../workflows/ingest-faire-order"
6
+
7
+ /**
8
+ * Create a Medusa order from a placed Faire order.
9
+ *
10
+ * Fired by the `faire.order_placed` (or `faire.order_created`) event the webhook
11
+ * receiver emits, kept off the webhook request path so the 200 stays fast. The
12
+ * Faire order is carried on the event; if it wasn't fetched at webhook time we
13
+ * refetch from resource_url.
14
+ *
15
+ * Kill switch: set FAIRE_INGEST_ORDERS=false to disable order creation while
16
+ * still recording webhook deliveries.
17
+ */
18
+ export default async function faireOrderIngestHandler({
19
+ event: { data },
20
+ container,
21
+ }: SubscriberArgs<{
22
+ resource?: any
23
+ resource_url?: string
24
+ webhook_id?: string
25
+ }>) {
26
+ if (process.env.FAIRE_INGEST_ORDERS === "false") return
27
+
28
+ const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
29
+ const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
30
+
31
+ let order: any = data?.resource
32
+ if (!order && data?.resource_url) {
33
+ try {
34
+ const account = await service.ensureFreshToken()
35
+ order = await service
36
+ .getClient()
37
+ .fetchResource((account as any).access_token, data.resource_url)
38
+ } catch (err: any) {
39
+ logger?.warn?.(`[faire-order] could not fetch order: ${err?.message}`)
40
+ return
41
+ }
42
+ }
43
+ if (!order) return
44
+
45
+ try {
46
+ await ingestFaireOrderWorkflow(container).run({
47
+ input: {
48
+ order,
49
+ order_token: order?.order_token != null ? String(order.order_token) : null,
50
+ },
51
+ })
52
+ } catch (err: any) {
53
+ logger?.warn?.(
54
+ `[faire-order] ingest failed for order ${order?.order_token}: ${err?.message}`
55
+ )
56
+ }
57
+ }
58
+
59
+ export const config: SubscriberConfig = {
60
+ event: ["faire.order_placed", "faire.order_created"],
61
+ }