@easypayment/medusa-payment-paypal 0.9.25 → 0.9.27
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 +183 -297
- package/.medusa/server/src/admin/index.mjs +183 -297
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +0 -1
- 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 +204 -368
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +11 -9
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/onboard-return/route.js +28 -9
- package/.medusa/server/src/api/store/paypal/onboard-return/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 +67 -55
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.js +15 -5
- package/.medusa/server/src/modules/paypal/payment-provider/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 +32 -6
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +10 -0
- package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.js +36 -0
- package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +230 -389
- package/src/api/store/paypal/onboard-return/route.ts +34 -9
- package/src/modules/paypal/payment-provider/card-service.ts +85 -65
- package/src/modules/paypal/payment-provider/service.ts +22 -7
- package/src/modules/paypal/service.ts +34 -7
- package/src/modules/paypal/utils/amounts.ts +36 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Public PayPal onboarding `return_url`.
|
|
@@ -9,16 +10,18 @@ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
|
9
10
|
* than `/admin/*`. Leaving the popup on a raw JSON / 401 / 405 page is exactly
|
|
10
11
|
* what made the popup appear to "never close".
|
|
11
12
|
*
|
|
12
|
-
* This route
|
|
13
|
-
* 1.
|
|
14
|
-
* (
|
|
15
|
-
*
|
|
13
|
+
* This route is the authoritative, partner.js-independent completion path. It:
|
|
14
|
+
* 1. exchanges PayPal's `authCode` + `sharedId` for seller credentials
|
|
15
|
+
* server-side (idempotent — safe even if partner.js's onboardingCallback or
|
|
16
|
+
* admin status polling also completes the exchange),
|
|
17
|
+
* 2. relays whatever query params PayPal appended back to the opener (the
|
|
18
|
+
* Medusa Admin connection page) via postMessage so it can refresh
|
|
19
|
+
* immediately, and
|
|
20
|
+
* 3. closes the popup.
|
|
16
21
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* guarantees the popup closes and the admin page re-checks status even if the
|
|
21
|
-
* in-flight PayPal completion message was missed.
|
|
22
|
+
* Because the exchange no longer depends on the opener receiving an in-flight
|
|
23
|
+
* postMessage, onboarding completes (and the admin page flips to "connected" via
|
|
24
|
+
* its status poll) even when partner.js never fires its callback.
|
|
22
25
|
*/
|
|
23
26
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
24
27
|
const query = (req.query || {}) as Record<string, unknown>
|
|
@@ -28,6 +31,28 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
|
28
31
|
params[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
// PayPal appends the seller's authorization code + shared id to the return_url
|
|
35
|
+
// (plus the env we pinned when generating the link). When present, complete the
|
|
36
|
+
// exchange right here so credentials are saved even if no other path runs.
|
|
37
|
+
const authCode = params.authCode || params.auth_code
|
|
38
|
+
const sharedId = params.sharedId || params.shared_id
|
|
39
|
+
const env =
|
|
40
|
+
params.env === "sandbox" || params.env === "live" ? params.env : undefined
|
|
41
|
+
|
|
42
|
+
if (authCode && sharedId) {
|
|
43
|
+
try {
|
|
44
|
+
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
45
|
+
await paypal.exchangeAndSaveSellerCredentials({ authCode, sharedId, env })
|
|
46
|
+
} catch (e: unknown) {
|
|
47
|
+
// Don't break the popup close on failure — relay the params so the opener
|
|
48
|
+
// (partner.js / admin polling) can still attempt to complete.
|
|
49
|
+
console.error(
|
|
50
|
+
"[PayPal] onboard-return exchange failed:",
|
|
51
|
+
e instanceof Error ? e.message : e
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
// JSON-encode then escape the characters that could break out of the inline
|
|
32
57
|
// <script> string/tag (including the U+2028/U+2029 separators that are legal in
|
|
33
58
|
// JSON but illegal in a JS string literal), so the relayed params embed safely.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AbstractPaymentProvider } from "@medusajs/framework/utils"
|
|
1
|
+
import { AbstractPaymentProvider, MedusaError } from "@medusajs/framework/utils"
|
|
2
2
|
import { randomUUID } from "crypto"
|
|
3
3
|
import type {
|
|
4
4
|
AuthorizePaymentInput,
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
WebhookActionResult,
|
|
26
26
|
} from "@medusajs/framework/types"
|
|
27
27
|
import { getPayPalWebhookActionAndData } from "./webhook-utils"
|
|
28
|
-
import { formatAmountForPayPal } from "../utils/amounts"
|
|
28
|
+
import { formatAmountForPayPal, toAmountNumber } from "../utils/amounts"
|
|
29
29
|
import { paypalFetch } from "../utils/paypal-fetch"
|
|
30
30
|
import {
|
|
31
31
|
assertPayPalCurrencySupported,
|
|
@@ -727,84 +727,104 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
|
|
|
727
727
|
const paypalData = (data.paypal || {}) as Record<string, any>
|
|
728
728
|
const captureId = String(paypalData.capture_id || data.capture_id || "")
|
|
729
729
|
if (!captureId) {
|
|
730
|
-
throw new
|
|
730
|
+
throw new MedusaError(
|
|
731
|
+
MedusaError.Types.INVALID_DATA,
|
|
732
|
+
"PayPal capture_id is required to refund payment. No capture found in session data."
|
|
733
|
+
)
|
|
731
734
|
}
|
|
732
735
|
|
|
733
|
-
const requestId = this.getIdempotencyKey(_input, `refund-${captureId}`)
|
|
734
736
|
// Use the refund amount Medusa passes (top-level input), not the session
|
|
735
737
|
// amount in `data` — otherwise a partial refund would refund the full order.
|
|
736
|
-
|
|
738
|
+
// Medusa passes it as a BigNumberInput, so coerce it to a number; a naive
|
|
739
|
+
// Number() of the object form yields NaN and silently refunds the full
|
|
740
|
+
// capture.
|
|
741
|
+
const amount = toAmountNumber(_input.amount)
|
|
742
|
+
const requestId = this.getIdempotencyKey(_input, `refund-${captureId}-${amount}`)
|
|
737
743
|
const currencyOverride = await this.resolveCurrencyOverride()
|
|
738
744
|
const currencyCode = normalizeCurrencyCode(
|
|
739
745
|
data.currency_code || currencyOverride || "EUR"
|
|
740
746
|
)
|
|
741
|
-
const { accessToken, base } = await this.getPayPalAccessToken()
|
|
742
|
-
const refundPayload: Record<string, any> =
|
|
743
|
-
amount > 0
|
|
744
|
-
? {
|
|
745
|
-
amount: {
|
|
746
|
-
currency_code: currencyCode,
|
|
747
|
-
value: formatAmountForPayPal(amount, currencyCode),
|
|
748
|
-
},
|
|
749
|
-
}
|
|
750
|
-
: {}
|
|
751
747
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
748
|
+
try {
|
|
749
|
+
const { accessToken, base } = await this.getPayPalAccessToken()
|
|
750
|
+
const refundPayload: Record<string, any> =
|
|
751
|
+
amount > 0
|
|
752
|
+
? {
|
|
753
|
+
amount: {
|
|
754
|
+
currency_code: currencyCode,
|
|
755
|
+
value: formatAmountForPayPal(amount, currencyCode),
|
|
756
|
+
},
|
|
757
|
+
}
|
|
758
|
+
: {}
|
|
762
759
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
760
|
+
const resp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
|
|
761
|
+
method: "POST",
|
|
762
|
+
headers: {
|
|
763
|
+
Authorization: `Bearer ${accessToken}`,
|
|
764
|
+
"Content-Type": "application/json",
|
|
765
|
+
"PayPal-Request-Id": requestId,
|
|
766
|
+
"PayPal-Partner-Attribution-Id": BN_CODE,
|
|
767
|
+
},
|
|
768
|
+
body: JSON.stringify(refundPayload),
|
|
769
|
+
})
|
|
772
770
|
|
|
773
|
-
|
|
771
|
+
const text = await resp.text()
|
|
772
|
+
if (!resp.ok) {
|
|
773
|
+
const debugId = resp.headers.get("paypal-debug-id")
|
|
774
|
+
throw new MedusaError(
|
|
775
|
+
MedusaError.Types.UNEXPECTED_STATE,
|
|
776
|
+
`PayPal refund error (${resp.status}): ${text}${
|
|
777
|
+
debugId ? ` debug_id=${debugId}` : ""
|
|
778
|
+
}`
|
|
779
|
+
)
|
|
780
|
+
}
|
|
774
781
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
782
|
+
const refund = JSON.parse(text)
|
|
783
|
+
|
|
784
|
+
// As with captures, a 2xx response does not guarantee the refund stuck.
|
|
785
|
+
// FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
|
|
786
|
+
// recorded as a successful refund. PENDING is accepted: PayPal processes
|
|
787
|
+
// refunds asynchronously and a pending refund will settle.
|
|
788
|
+
const refundStatus = String(refund?.status || "").toUpperCase()
|
|
789
|
+
if (isRefundFailureStatus(refundStatus)) {
|
|
790
|
+
const refundDebugId = resp.headers.get("paypal-debug-id")
|
|
791
|
+
throw new MedusaError(
|
|
792
|
+
MedusaError.Types.UNEXPECTED_STATE,
|
|
793
|
+
`PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
|
|
794
|
+
(refundDebugId ? ` debug_id=${refundDebugId}` : "")
|
|
795
|
+
)
|
|
796
|
+
}
|
|
787
797
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
798
|
+
const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
|
|
799
|
+
const refundEntry = {
|
|
800
|
+
id: refund?.id,
|
|
801
|
+
status: refund?.status,
|
|
802
|
+
amount: refund?.amount,
|
|
803
|
+
raw: refund,
|
|
804
|
+
}
|
|
795
805
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
806
|
+
return {
|
|
807
|
+
data: {
|
|
808
|
+
...(data || {}),
|
|
809
|
+
paypal: {
|
|
810
|
+
...paypalData,
|
|
811
|
+
refund_id: refund?.id,
|
|
812
|
+
refund_status: refund?.status,
|
|
813
|
+
refunds: [...existingRefunds, refundEntry],
|
|
814
|
+
refund,
|
|
815
|
+
},
|
|
816
|
+
refunded_at: new Date().toISOString(),
|
|
805
817
|
},
|
|
806
|
-
|
|
807
|
-
|
|
818
|
+
}
|
|
819
|
+
} catch (error: any) {
|
|
820
|
+
// Surface the real reason: Medusa masks any non-MedusaError as a generic
|
|
821
|
+
// "An unknown error occurred." in production, hiding the PayPal failure.
|
|
822
|
+
throw error instanceof MedusaError
|
|
823
|
+
? error
|
|
824
|
+
: new MedusaError(
|
|
825
|
+
MedusaError.Types.UNEXPECTED_STATE,
|
|
826
|
+
error?.message || "PayPal refund failed."
|
|
827
|
+
)
|
|
808
828
|
}
|
|
809
829
|
}
|
|
810
830
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AbstractPaymentProvider } from "@medusajs/framework/utils"
|
|
1
|
+
import { AbstractPaymentProvider, MedusaError } from "@medusajs/framework/utils"
|
|
2
2
|
import { randomUUID } from "crypto"
|
|
3
3
|
import type {
|
|
4
4
|
AuthorizePaymentInput,
|
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
ProviderWebhookPayload,
|
|
25
25
|
WebhookActionResult,
|
|
26
26
|
} from "@medusajs/framework/types"
|
|
27
|
-
import { formatAmountForPayPal, getCurrencyExponent } from "../utils/amounts"
|
|
27
|
+
import { formatAmountForPayPal, getCurrencyExponent, toAmountNumber } from "../utils/amounts"
|
|
28
28
|
import {
|
|
29
29
|
assertPayPalCurrencySupported,
|
|
30
30
|
normalizeCurrencyCode,
|
|
@@ -773,16 +773,22 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
773
773
|
paypalData.refund_reason_code || data.refund_reason_code || data.reason_code || ""
|
|
774
774
|
).trim()
|
|
775
775
|
if (!captureId) {
|
|
776
|
-
throw new
|
|
776
|
+
throw new MedusaError(
|
|
777
|
+
MedusaError.Types.INVALID_DATA,
|
|
777
778
|
"PayPal capture_id is required to refund payment. No capture found in session data."
|
|
778
779
|
)
|
|
779
780
|
}
|
|
780
781
|
|
|
782
|
+
// Medusa passes the refund amount as a BigNumberInput (BigNumber instance or
|
|
783
|
+
// serialized { value, precision }); coerce it to a number so partial refunds
|
|
784
|
+
// send the correct value instead of silently refunding the full capture.
|
|
785
|
+
const refundAmount = toAmountNumber(input.amount)
|
|
786
|
+
|
|
781
787
|
// Include the amount in the idempotency suffix so two sequential partial
|
|
782
788
|
// refunds of the same capture are not deduplicated by PayPal into one.
|
|
783
789
|
const requestId = this.getIdempotencyKey(
|
|
784
790
|
input,
|
|
785
|
-
`refund-${captureId}-${
|
|
791
|
+
`refund-${captureId}-${refundAmount}`
|
|
786
792
|
)
|
|
787
793
|
|
|
788
794
|
const currencyOverride = await this.resolveCurrencyOverride()
|
|
@@ -790,7 +796,6 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
790
796
|
data.currency_code || currencyOverride || "EUR"
|
|
791
797
|
)
|
|
792
798
|
|
|
793
|
-
const refundAmount = Number(input.amount ?? 0)
|
|
794
799
|
const refundValue = refundAmount > 0
|
|
795
800
|
? formatAmountForPayPal(refundAmount, currencyCode)
|
|
796
801
|
: null
|
|
@@ -810,7 +815,9 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
810
815
|
: {}
|
|
811
816
|
|
|
812
817
|
if (refundReason) {
|
|
813
|
-
|
|
818
|
+
// PayPal rejects a note_to_payer longer than 255 characters with a 422
|
|
819
|
+
// (which would otherwise fail the whole refund); truncate defensively.
|
|
820
|
+
refundPayload.note_to_payer = refundReason.slice(0, 255)
|
|
814
821
|
}
|
|
815
822
|
|
|
816
823
|
const ppResp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
|
|
@@ -891,7 +898,15 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
891
898
|
debug_id: debugId,
|
|
892
899
|
message: error?.message,
|
|
893
900
|
})
|
|
894
|
-
|
|
901
|
+
// Surface the real reason to the admin. Medusa's error handler masks any
|
|
902
|
+
// non-MedusaError as a generic "An unknown error occurred." in production,
|
|
903
|
+
// which hid the actual PayPal failure from the order refund UI.
|
|
904
|
+
throw error instanceof MedusaError
|
|
905
|
+
? error
|
|
906
|
+
: new MedusaError(
|
|
907
|
+
MedusaError.Types.UNEXPECTED_STATE,
|
|
908
|
+
error?.message || "PayPal refund failed."
|
|
909
|
+
)
|
|
895
910
|
}
|
|
896
911
|
}
|
|
897
912
|
|
|
@@ -495,17 +495,20 @@ 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
|
-
// 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`
|
|
503
498
|
// Honor an explicit environment from the caller so the generated link can't
|
|
504
499
|
// depend on a racing "set environment" request having landed first.
|
|
505
500
|
const env =
|
|
506
501
|
input?.env === "sandbox" || input?.env === "live"
|
|
507
502
|
? input.env
|
|
508
503
|
: await this.getCurrentEnvironment()
|
|
504
|
+
// The popup lands here after onboarding, as a plain top-level navigation with
|
|
505
|
+
// no auth token — so it must be the PUBLIC store bridge route, not an
|
|
506
|
+
// /admin/* route (which would 401 and leave the popup stuck). The bridge
|
|
507
|
+
// exchanges the auth code for seller credentials server-side, relays the
|
|
508
|
+
// result to the opener, and closes the popup. We pin the environment in the
|
|
509
|
+
// URL so the bridge saves credentials under the correct env even though
|
|
510
|
+
// PayPal's redirect carries no auth/session.
|
|
511
|
+
const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/store/paypal/onboard-return?env=${env}`
|
|
509
512
|
const partner_merchant_id = await this.getPartnerMerchantId(env)
|
|
510
513
|
|
|
511
514
|
const email = (input?.email || "").trim()
|
|
@@ -653,9 +656,23 @@ class PayPalModuleService extends MedusaService({
|
|
|
653
656
|
sharedId: string
|
|
654
657
|
env?: "sandbox" | "live"
|
|
655
658
|
}) {
|
|
656
|
-
await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
|
|
657
|
-
|
|
658
659
|
const env = (input.env || (await this.getCurrentEnvironment())) as Environment
|
|
660
|
+
|
|
661
|
+
// Onboarding completion can now arrive via several redundant paths
|
|
662
|
+
// (partner.js's onboardingCallback in the opener, the return_url bridge that
|
|
663
|
+
// runs inside the popup, and admin status polling). PayPal's authorization
|
|
664
|
+
// code is single-use, so a second exchange of the same code would fail with
|
|
665
|
+
// invalid_grant. If we already exchanged this exact code and hold seller
|
|
666
|
+
// credentials for this environment, treat the duplicate as a success no-op.
|
|
667
|
+
const existingRow = await this.getCurrentRow()
|
|
668
|
+
if (existingRow && existingRow.auth_code === input.authCode) {
|
|
669
|
+
const existingCreds = this.getEnvCreds(existingRow, env)
|
|
670
|
+
if (existingCreds.clientId && existingCreds.clientSecret) {
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
|
|
659
676
|
const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
|
|
660
677
|
|
|
661
678
|
const { onboarding } = await this.ensureSettingsDefaults()
|
|
@@ -690,6 +707,16 @@ class PayPalModuleService extends MedusaService({
|
|
|
690
707
|
}
|
|
691
708
|
|
|
692
709
|
if (!tokenRes.ok) {
|
|
710
|
+
// Two redundant completion paths can race to exchange the same single-use
|
|
711
|
+
// code; the loser gets invalid_grant. If credentials were saved in the
|
|
712
|
+
// meantime (the other path won), the onboarding actually succeeded.
|
|
713
|
+
const racedRow = await this.getCurrentRow()
|
|
714
|
+
if (racedRow) {
|
|
715
|
+
const racedCreds = this.getEnvCreds(racedRow, env)
|
|
716
|
+
if (racedCreds.clientId && racedCreds.clientSecret) {
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
}
|
|
693
720
|
throw new Error(
|
|
694
721
|
`PayPal authorization_code token exchange failed (${tokenRes.status}): ${tokenText || JSON.stringify(tokenJson)}`
|
|
695
722
|
)
|
|
@@ -36,6 +36,42 @@ export function getCurrencyExponent(currencyCode: string) {
|
|
|
36
36
|
return 2
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Coerce a Medusa monetary amount into a plain JS number.
|
|
41
|
+
*
|
|
42
|
+
* Medusa passes amounts to payment providers as a `BigNumberInput`, which can be
|
|
43
|
+
* a plain `number`/`string`, a `BigNumber` instance (exposing a `numeric`
|
|
44
|
+
* getter), or the serialized raw form `{ value, precision }`. A naive
|
|
45
|
+
* `Number(amount)` returns `NaN` for the object forms — which silently turns a
|
|
46
|
+
* partial refund/capture into a full one and produces a `…-NaN` idempotency key.
|
|
47
|
+
*/
|
|
48
|
+
export function toAmountNumber(amount: unknown): number {
|
|
49
|
+
if (amount === null || amount === undefined) {
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
if (typeof amount === "number") {
|
|
53
|
+
return Number.isFinite(amount) ? amount : 0
|
|
54
|
+
}
|
|
55
|
+
if (typeof amount === "string") {
|
|
56
|
+
const parsed = Number(amount)
|
|
57
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
58
|
+
}
|
|
59
|
+
if (typeof amount === "object") {
|
|
60
|
+
const obj = amount as Record<string, unknown>
|
|
61
|
+
// Medusa BigNumber instance.
|
|
62
|
+
if (typeof obj.numeric === "number" && Number.isFinite(obj.numeric)) {
|
|
63
|
+
return obj.numeric
|
|
64
|
+
}
|
|
65
|
+
// Serialized raw form: { value: "20", precision: 20 }.
|
|
66
|
+
if (obj.value !== undefined && obj.value !== null) {
|
|
67
|
+
const parsed = Number(obj.value)
|
|
68
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const fallback = Number(amount as never)
|
|
72
|
+
return Number.isFinite(fallback) ? fallback : 0
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
/**
|
|
40
76
|
* Format a Medusa amount for the PayPal REST API.
|
|
41
77
|
*
|