@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.
- package/.medusa/server/src/admin/index.js +264 -80
- package/.medusa/server/src/admin/index.mjs +264 -80
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +340 -146
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js +6 -8
- package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/environment/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/environment/route.js +11 -3
- package/.medusa/server/src/api/admin/paypal/environment/route.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts +7 -0
- package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js +7 -0
- package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-status/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
- package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
- package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
- package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +23 -0
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -0
- package/.medusa/server/src/api/store/paypal/onboard-return/route.js +63 -0
- package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -0
- package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
- package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +5 -1
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
- package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
- package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +364 -160
- package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
- package/src/api/admin/paypal/environment/route.ts +10 -3
- package/src/api/admin/paypal/onboard-complete/route.ts +7 -0
- package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
- package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
- package/src/api/store/paypal/capture-order/route.ts +15 -0
- package/src/api/store/paypal/onboard-return/route.ts +63 -0
- package/src/api/store/paypal-complete/route.ts +6 -4
- package/src/modules/paypal/payment-provider/card-service.ts +72 -38
- package/src/modules/paypal/service.ts +5 -1
- 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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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: "
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
528
|
-
amount
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
+
}
|