@easypayment/medusa-payment-paypal 0.9.22 → 0.9.25

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 (54) hide show
  1. package/.medusa/server/src/admin/index.js +264 -80
  2. package/.medusa/server/src/admin/index.mjs +264 -80
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +340 -146
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  6. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  7. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js +6 -8
  8. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  9. package/.medusa/server/src/api/admin/paypal/environment/route.d.ts.map +1 -1
  10. package/.medusa/server/src/api/admin/paypal/environment/route.js +11 -3
  11. package/.medusa/server/src/api/admin/paypal/environment/route.js.map +1 -1
  12. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts +7 -0
  13. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js +7 -0
  15. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js.map +1 -1
  16. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.d.ts.map +1 -1
  17. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
  18. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
  19. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  20. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
  21. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  22. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  23. package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
  24. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  25. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +23 -0
  26. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -0
  27. package/.medusa/server/src/api/store/paypal/onboard-return/route.js +63 -0
  28. package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -0
  29. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  30. package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
  31. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  32. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  33. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
  34. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  35. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  36. package/.medusa/server/src/modules/paypal/service.js +5 -1
  37. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  38. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
  39. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
  40. package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
  41. package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/admin/routes/settings/paypal/connection/page.tsx +364 -160
  44. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
  45. package/src/api/admin/paypal/environment/route.ts +10 -3
  46. package/src/api/admin/paypal/onboard-complete/route.ts +7 -0
  47. package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
  48. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
  49. package/src/api/store/paypal/capture-order/route.ts +15 -0
  50. package/src/api/store/paypal/onboard-return/route.ts +63 -0
  51. package/src/api/store/paypal-complete/route.ts +6 -4
  52. package/src/modules/paypal/payment-provider/card-service.ts +72 -38
  53. package/src/modules/paypal/service.ts +5 -1
  54. package/src/modules/paypal/utils/core-workflow.ts +46 -0
@@ -1,5 +1,5 @@
1
- import { createPaymentSessionsWorkflow } from "@medusajs/core-flows"
2
1
  import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { runCoreWorkflow } from "../../../../../modules/paypal/utils/core-workflow"
3
3
 
4
4
  type CreatePaymentSessionBody = {
5
5
  provider_id: string
@@ -25,13 +25,11 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
25
25
  }
26
26
 
27
27
  try {
28
- const { result } = await createPaymentSessionsWorkflow(req.scope).run({
29
- input: {
30
- payment_collection_id: collectionId,
31
- provider_id,
32
- customer_id,
33
- data,
34
- },
28
+ const { result } = await runCoreWorkflow(req.scope, "create-payment-sessions", {
29
+ payment_collection_id: collectionId,
30
+ provider_id,
31
+ customer_id,
32
+ data,
35
33
  })
36
34
 
37
35
  res.status(200).json({ payment_session: result })
@@ -6,9 +6,16 @@ type Body = {
6
6
  }
7
7
 
8
8
  export async function GET(req: MedusaRequest, res: MedusaResponse) {
9
- const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
10
- const status = await paypal.getStatus()
11
- return res.json({ environment: status.environment })
9
+ try {
10
+ const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
11
+ const status = await paypal.getStatus()
12
+ return res.json({ environment: status.environment })
13
+ } catch {
14
+ // This GET runs on every connection-page load; an unguarded rejection here
15
+ // (no connection row yet, transient DB / decrypt error) would surface as an
16
+ // unhandled 500. Fail soft so the admin page can still render.
17
+ return res.status(500).json({ message: "Failed to load environment" })
18
+ }
12
19
  }
13
20
 
14
21
  export async function POST(req: MedusaRequest, res: MedusaResponse) {
@@ -1,6 +1,13 @@
1
1
  import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
2
  import type PayPalModuleService from "../../../../modules/paypal/service"
3
3
 
4
+ /**
5
+ * The PayPal onboarding `return_url` (the page the mini-browser popup lands on)
6
+ * is the PUBLIC route `GET /store/paypal/onboard-return` — `/admin/*` routes
7
+ * require an auth token that a top-level popup navigation cannot carry, which
8
+ * would leave the popup stuck on a 401 page. This admin GET is therefore not part
9
+ * of the browser flow; it only documents the contract for the POST below.
10
+ */
4
11
  export async function GET(_req: MedusaRequest, res: MedusaResponse) {
5
12
  return res.status(405).json({
6
13
  message:
@@ -9,8 +9,11 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
9
9
  } catch (e: unknown) {
10
10
  const message = e instanceof Error ? e.message : String(e)
11
11
  console.error("[paypal_onboarding] onboarding-status error:", message)
12
+ // Do NOT report a definitive `status: "disconnected"` here — a transient DB
13
+ // or decrypt error would then look like the merchant is not connected and
14
+ // could trigger a needless re-onboarding. Signal the error with `unknown`.
12
15
  return res.status(500).json({
13
- status: "disconnected",
16
+ status: "unknown",
14
17
  message: "Failed to retrieve PayPal status",
15
18
  })
16
19
  }
@@ -1,6 +1,6 @@
1
- import { createPaymentSessionsWorkflow } from "@medusajs/core-flows"
2
1
  import { MedusaResponse, MedusaStoreRequest, refetchEntity } from "@medusajs/framework/http"
3
2
  import { MedusaError } from "@medusajs/framework/utils"
3
+ import { runCoreWorkflow } from "../../../../../modules/paypal/utils/core-workflow"
4
4
 
5
5
  type CreatePaymentSessionBody = {
6
6
  provider_id: string
@@ -27,13 +27,11 @@ export async function POST(req: MedusaStoreRequest, res: MedusaResponse) {
27
27
  }
28
28
 
29
29
  try {
30
- await createPaymentSessionsWorkflow(req.scope).run({
31
- input: {
32
- payment_collection_id: collectionId,
33
- provider_id,
34
- customer_id: req.auth_context?.actor_id,
35
- data,
36
- },
30
+ await runCoreWorkflow(req.scope, "create-payment-sessions", {
31
+ payment_collection_id: collectionId,
32
+ provider_id,
33
+ customer_id: req.auth_context?.actor_id,
34
+ data,
37
35
  })
38
36
  } catch (error) {
39
37
  const message = error instanceof Error ? error.message : String(error)
@@ -5,6 +5,7 @@ import { randomUUID } from "crypto"
5
5
  import type PayPalModuleService from "../../../../modules/paypal/service"
6
6
  import { getPayPalApiBase } from "../../../../modules/paypal/utils/paypal-auth"
7
7
  import { paypalFetch } from "../../../../modules/paypal/utils/paypal-fetch"
8
+ import { extractCaptureStatus } from "../../../../modules/paypal/payment-provider/status-utils"
8
9
  import {
9
10
  findPayPalSessionForCart,
10
11
  getStoredPayPalOrderId,
@@ -204,6 +205,20 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
204
205
  if (paymentAction === "authorize") {
205
206
  await attachPayPalAuthorizationToSession(cartId, orderId, payload, req.scope)
206
207
  } else {
208
+ // A 2xx capture response does NOT guarantee the funds settled: PayPal
209
+ // returns 201 for PENDING (pending review / eCheck), DECLINED and FAILED
210
+ // captures too. Returning those to the storefront as a successful capture
211
+ // would let it finalize the cart for a payment that never completed, so
212
+ // only a COMPLETED capture is reported as success — the webhook will
213
+ // reconcile a later PENDING→COMPLETED transition.
214
+ const captureStatus = extractCaptureStatus(payload)
215
+ if (captureStatus !== "COMPLETED") {
216
+ throw new Error(
217
+ `PayPal capture did not complete (status=${captureStatus || "UNKNOWN"})${
218
+ debugId ? ` debug_id=${debugId}` : ""
219
+ }`
220
+ )
221
+ }
207
222
  await attachPayPalCaptureToSession(cartId, orderId, payload, req.scope)
208
223
  }
209
224
 
@@ -0,0 +1,63 @@
1
+ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+
3
+ /**
4
+ * Public PayPal onboarding `return_url`.
5
+ *
6
+ * When a seller finishes onboarding, PayPal redirects the mini-browser popup to
7
+ * this URL as a plain top-level navigation (no auth header / no publishable key),
8
+ * which is why it must live under the public `/store/paypal/*` namespace rather
9
+ * than `/admin/*`. Leaving the popup on a raw JSON / 401 / 405 page is exactly
10
+ * what made the popup appear to "never close".
11
+ *
12
+ * This route serves a tiny HTML bridge that runs inside the popup and:
13
+ * 1. relays whatever query params PayPal appended back to the opener
14
+ * (the Medusa Admin connection page) via postMessage, and
15
+ * 2. closes the popup.
16
+ *
17
+ * The actual credential exchange happens via the admin POST
18
+ * /admin/paypal/onboard-complete (driven by partner.js's onboardingCallback and
19
+ * the admin page's postMessage listener). This page is the safety net that
20
+ * guarantees the popup closes and the admin page re-checks status even if the
21
+ * in-flight PayPal completion message was missed.
22
+ */
23
+ export async function GET(req: MedusaRequest, res: MedusaResponse) {
24
+ const query = (req.query || {}) as Record<string, unknown>
25
+ const params: Record<string, string> = {}
26
+ for (const [k, v] of Object.entries(query)) {
27
+ if (v == null) continue
28
+ params[k] = Array.isArray(v) ? String(v[0]) : String(v)
29
+ }
30
+
31
+ // JSON-encode then escape the characters that could break out of the inline
32
+ // <script> string/tag (including the U+2028/U+2029 separators that are legal in
33
+ // JSON but illegal in a JS string literal), so the relayed params embed safely.
34
+ const safePayload = JSON.stringify(params)
35
+ .replace(/</g, "\\u003c")
36
+ .replace(/>/g, "\\u003e")
37
+ .replace(/&/g, "\\u0026")
38
+ .replace(/[\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"))
39
+
40
+ const html = `<!doctype html>
41
+ <html lang="en">
42
+ <head><meta charset="utf-8" /><title>PayPal onboarding complete</title></head>
43
+ <body style="font-family: system-ui, sans-serif; padding: 24px; text-align: center; color: #1f2937;">
44
+ <p>PayPal onboarding complete. You can close this window.</p>
45
+ <script>
46
+ (function () {
47
+ try {
48
+ var message = { source: "paypal-onboarding-return", params: ${safePayload} };
49
+ if (window.opener && !window.opener.closed) {
50
+ try { window.opener.postMessage(message, "*"); } catch (e) {}
51
+ }
52
+ } catch (e) {}
53
+ // Give the opener a moment to react, then close the popup.
54
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 600);
55
+ })();
56
+ </script>
57
+ </body>
58
+ </html>`
59
+
60
+ res.setHeader("Content-Type", "text/html; charset=utf-8")
61
+ res.setHeader("Cache-Control", "no-store")
62
+ return res.status(200).send(html)
63
+ }
@@ -1,9 +1,9 @@
1
1
  import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
2
  import type { IPaymentModuleService, Logger } from "@medusajs/framework/types"
3
3
  import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
4
- import { completeCartWorkflow } from "@medusajs/core-flows"
5
4
  import { randomUUID } from "crypto"
6
5
  import { isPayPalProviderId } from "../../../modules/paypal/utils/provider-ids"
6
+ import { runCoreWorkflow } from "../../../modules/paypal/utils/core-workflow"
7
7
 
8
8
  export async function POST(req: MedusaRequest, res: MedusaResponse) {
9
9
  const logger = req.scope.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
@@ -80,9 +80,11 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
80
80
  }
81
81
 
82
82
  try {
83
- const { result } = await completeCartWorkflow(req.scope).run({
84
- input: { id: cart_id },
85
- })
83
+ const { result } = await runCoreWorkflow<{ id: string }, { id?: string }>(
84
+ req.scope,
85
+ "complete-cart",
86
+ { id: cart_id }
87
+ )
86
88
  logger.info(
87
89
  `[paypal] paypal-complete cart completed (request_id=${requestId}, cart_id=${cart_id}, order_id=${result?.id})`
88
90
  )
@@ -31,6 +31,12 @@ import {
31
31
  assertPayPalCurrencySupported,
32
32
  normalizeCurrencyCode,
33
33
  } from "../utils/currencies"
34
+ import {
35
+ extractCaptureStatus,
36
+ isCaptureCompleted,
37
+ isRefundFailureStatus,
38
+ mapPayPalCaptureStatus,
39
+ } from "./status-utils"
34
40
  import type PayPalModuleService from "../service"
35
41
 
36
42
  type Options = {}
@@ -154,23 +160,12 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
154
160
  }
155
161
 
156
162
  private mapCaptureStatus(status?: string) {
157
- const normalized = String(status || "").toUpperCase()
158
- if (!normalized) {
159
- return null
160
- }
161
- if (normalized === "COMPLETED") {
162
- return "captured"
163
- }
164
- if (normalized === "PENDING") {
165
- return "pending"
166
- }
167
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
168
- return "error"
169
- }
170
- if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) {
171
- return "canceled"
172
- }
173
- return null
163
+ // Delegate to the shared, unit-tested mapping so the card provider agrees
164
+ // with the wallet provider and the webhook processor. In particular a
165
+ // PARTIALLY_REFUNDED capture must stay "captured" (only part of the funds
166
+ // were returned) — mapping it to "canceled" would wrongly unwind a live
167
+ // capture.
168
+ return mapPayPalCaptureStatus(status)
174
169
  }
175
170
 
176
171
  private mapAuthorizationStatus(status?: string) {
@@ -478,7 +473,7 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
478
473
  const { accessToken, base } = await this.getPayPalAccessToken()
479
474
  const order = await this.getOrderDetails(orderId).catch(() => null)
480
475
  const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
481
- if (existingCapture?.id) {
476
+ if (existingCapture?.id && isCaptureCompleted(existingCapture)) {
482
477
  return {
483
478
  data: {
484
479
  ...(data || {}),
@@ -524,26 +519,38 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
524
519
  data.is_final_capture ??
525
520
  data.final_capture ??
526
521
  undefined
527
- const capturePayload =
528
- amount > 0
529
- ? {
530
- amount: {
531
- currency_code: currencyCode || "EUR",
532
- value: formatAmountForPayPal(amount, currencyCode || "EUR"),
533
- },
534
- ...(typeof isFinalCapture === "boolean"
535
- ? { is_final_capture: isFinalCapture }
536
- : {}),
537
- }
538
- : {
539
- ...(typeof isFinalCapture === "boolean"
540
- ? { is_final_capture: isFinalCapture }
541
- : {}),
542
- }
543
-
544
- const captureUrl = authorizationId
545
- ? `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
546
- : `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
522
+ const captureValue = amount > 0
523
+ ? formatAmountForPayPal(amount, currencyCode || "EUR")
524
+ : null
525
+
526
+ // `amount` and `is_final_capture` are only honored on the authorizations
527
+ // capture endpoint. The orders capture endpoint always captures the FULL
528
+ // order and silently ignores an `amount` body — so a partial amount there
529
+ // would over-capture while we record the smaller requested value. Route
530
+ // partial captures through the authorization, and fail closed if a partial
531
+ // capture is attempted against a capture-intent order.
532
+ let capturePayload: Record<string, unknown>
533
+ let captureUrl: string
534
+ if (authorizationId) {
535
+ captureUrl = `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
536
+ capturePayload = {
537
+ ...(captureValue
538
+ ? { amount: { currency_code: currencyCode || "EUR", value: captureValue } }
539
+ : {}),
540
+ ...(typeof isFinalCapture === "boolean" ? { is_final_capture: isFinalCapture } : {}),
541
+ }
542
+ } else {
543
+ captureUrl = `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
544
+ capturePayload = {}
545
+ const orderTotal = order?.purchase_units?.[0]?.amount?.value
546
+ if (captureValue && orderTotal && captureValue !== String(orderTotal)) {
547
+ throw new Error(
548
+ `PayPal partial capture (${captureValue} ${currencyCode || "EUR"}) is not supported for ` +
549
+ `capture-intent orders (order total ${orderTotal}). Create the order with intent ` +
550
+ `AUTHORIZE to capture a partial amount.`
551
+ )
552
+ }
553
+ }
547
554
 
548
555
  const ppResp = await paypalFetch(captureUrl, {
549
556
  method: "POST",
@@ -567,6 +574,19 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
567
574
  }
568
575
 
569
576
  const capture = JSON.parse(ppText)
577
+
578
+ // A 2xx response does NOT mean the funds were captured. PayPal returns 201
579
+ // for captures that are PENDING (pending review / eCheck), DECLINED, or
580
+ // FAILED. Recording any of these as "captured" books money that never
581
+ // settled, so only a COMPLETED capture is treated as success.
582
+ const captureStatus = extractCaptureStatus(capture)
583
+ if (captureStatus !== "COMPLETED") {
584
+ throw new Error(
585
+ `PayPal capture did not complete (status=${captureStatus || "UNKNOWN"}). ` +
586
+ `The payment was not captured.${debugId ? ` debug_id=${debugId}` : ""}`
587
+ )
588
+ }
589
+
570
590
  const captureId =
571
591
  capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
572
592
  const existingCaptures = Array.isArray(paypalData.captures) ? paypalData.captures : []
@@ -751,6 +771,20 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
751
771
  }
752
772
 
753
773
  const refund = JSON.parse(text)
774
+
775
+ // As with captures, a 2xx response does not guarantee the refund stuck.
776
+ // FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
777
+ // recorded as a successful refund. PENDING is accepted: PayPal processes
778
+ // refunds asynchronously and a pending refund will settle.
779
+ const refundStatus = String(refund?.status || "").toUpperCase()
780
+ if (isRefundFailureStatus(refundStatus)) {
781
+ const refundDebugId = resp.headers.get("paypal-debug-id")
782
+ throw new Error(
783
+ `PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
784
+ (refundDebugId ? ` debug_id=${refundDebugId}` : "")
785
+ )
786
+ }
787
+
754
788
  const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
755
789
  const refundEntry = {
756
790
  id: refund?.id,
@@ -495,7 +495,11 @@ class PayPalModuleService extends MedusaService({
495
495
 
496
496
  async createOnboardingLink(input?: { email?: string; products?: string[]; env?: Environment }) {
497
497
  const { onboarding } = await this.ensureSettingsDefaults()
498
- const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/admin/paypal/onboard-complete`
498
+ // The popup lands here after onboarding, as a plain top-level navigation with
499
+ // no auth token — so it must be the PUBLIC store bridge route, not an
500
+ // /admin/* route (which would 401 and leave the popup stuck). The bridge
501
+ // relays the result to the opener and closes the popup.
502
+ const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/store/paypal/onboard-return`
499
503
  // Honor an explicit environment from the caller so the generated link can't
500
504
  // depend on a racing "set environment" request having landed first.
501
505
  const env =
@@ -0,0 +1,46 @@
1
+ import { MedusaWorkflow } from "@medusajs/framework/workflows-sdk"
2
+ import type { MedusaContainer } from "@medusajs/framework"
3
+
4
+ /**
5
+ * Run a Medusa *core* workflow by its registered id, without importing it from
6
+ * `@medusajs/core-flows`.
7
+ *
8
+ * Why this exists:
9
+ * Importing anything from `@medusajs/core-flows` (e.g.
10
+ * `import { completeCartWorkflow } from "@medusajs/core-flows"`) pulls in the
11
+ * entire core-flows barrel. Every workflow module in that barrel calls
12
+ * `createWorkflow(...)` at import time, which registers the workflow in the
13
+ * global `WorkflowManager`. When this plugin is resolved as a *separate* copy
14
+ * of `@medusajs/core-flows` from the host application — which is exactly what
15
+ * happens when the plugin is consumed as a locally-linked package living
16
+ * outside the app's `node_modules` (e.g. a sibling folder with its own
17
+ * `node_modules`) — those registrations run a second time and collide with the
18
+ * host's already-registered workflows. Medusa then aborts boot with:
19
+ *
20
+ * An error occurred while registering API Routes.
21
+ * Error: Workflow with id "create-fulfillment-workflow" and step definition
22
+ * already exists.
23
+ *
24
+ * (The id is whichever core workflow happens to register first.)
25
+ *
26
+ * The host registers every core workflow into the global `MedusaWorkflow`
27
+ * registry at startup. Looking the workflow up there and running it reuses that
28
+ * single registered instance and never triggers a second registration, so the
29
+ * plugin works regardless of how it is installed or linked.
30
+ */
31
+ export async function runCoreWorkflow<TInput = unknown, TResult = unknown>(
32
+ container: MedusaContainer,
33
+ workflowId: string,
34
+ input: TInput
35
+ ): Promise<{ result: TResult }> {
36
+ const workflow = MedusaWorkflow.getWorkflow(workflowId)
37
+
38
+ if (!workflow) {
39
+ throw new Error(
40
+ `Core workflow "${workflowId}" is not registered. ` +
41
+ `Ensure the Medusa application has loaded its core workflows.`
42
+ )
43
+ }
44
+
45
+ return workflow(container).run({ input })
46
+ }