@easypayment/medusa-payment-paypal 0.9.22 → 0.9.24
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 +262 -92
- package/.medusa/server/src/admin/index.mjs +262 -92
- 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 +304 -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/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-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/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 +330 -161
- 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/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-complete/route.ts +6 -4
- package/src/modules/paypal/payment-provider/card-service.ts +72 -38
- 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) {
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -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
|
+
}
|