@easypayment/medusa-paypal 0.4.7 → 0.4.8
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 +7 -7
- package/.medusa/server/src/admin/index.mjs +7 -7
- package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/create-order/route.js +62 -139
- package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
- package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
- package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
- package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
- package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
- package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
- package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
- package/README.md +142 -142
- package/package.json +75 -75
- package/src/admin/index.ts +7 -7
- package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
- package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
- package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
- package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
- package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
- package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
- package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
- package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
- package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
- package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
- package/src/api/admin/paypal/disconnect/route.ts +8 -8
- package/src/api/admin/paypal/environment/route.ts +25 -25
- package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
- package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
- package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
- package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
- package/src/api/admin/paypal/save-credentials/route.ts +14 -14
- package/src/api/admin/paypal/settings/route.ts +14 -14
- package/src/api/admin/paypal/status/route.ts +12 -12
- package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
- package/src/api/store/paypal/capture-order/route.ts +276 -276
- package/src/api/store/paypal/config/route.ts +102 -102
- package/src/api/store/paypal/create-order/route.ts +77 -176
- package/src/api/store/paypal/settings/route.ts +19 -19
- package/src/api/store/paypal/webhook/route.ts +246 -246
- package/src/api/store/paypal-complete/route.ts +75 -75
- package/src/jobs/paypal-reconcile.ts +112 -112
- package/src/jobs/paypal-webhook-retry.ts +85 -85
- package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
- package/src/modules/paypal/index.ts +8 -8
- package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
- package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
- package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
- package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
- package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
- package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
- package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
- package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
- package/src/modules/paypal/models/paypal_connection.ts +21 -21
- package/src/modules/paypal/models/paypal_metric.ts +9 -9
- package/src/modules/paypal/models/paypal_settings.ts +8 -8
- package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
- package/src/modules/paypal/payment-provider/README.md +22 -22
- package/src/modules/paypal/payment-provider/card-service.ts +760 -760
- package/src/modules/paypal/payment-provider/index.ts +19 -19
- package/src/modules/paypal/payment-provider/service.ts +1121 -1121
- package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
- package/src/modules/paypal/service.ts +1247 -1247
- package/src/modules/paypal/types/config.ts +47 -47
- package/src/modules/paypal/utils/amounts.ts +41 -41
- package/src/modules/paypal/utils/crypto.ts +51 -51
- package/src/modules/paypal/utils/currencies.ts +84 -84
- package/src/modules/paypal/utils/paypal-auth.ts +32 -32
- package/src/modules/paypal/utils/provider-ids.ts +15 -15
- package/src/modules/paypal/webhook-processor.ts +215 -215
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
-
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
3
|
-
import {
|
|
4
|
-
getPayPalCurrencyCompatibility,
|
|
5
|
-
getPayPalSupportedCurrencies,
|
|
6
|
-
normalizeCurrencyCode,
|
|
7
|
-
} from "../../../../modules/paypal/utils/currencies"
|
|
8
|
-
|
|
9
|
-
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
10
|
-
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
11
|
-
try {
|
|
12
|
-
const creds = await paypal.getActiveCredentials()
|
|
13
|
-
const apiDetails = await paypal.getApiDetails().catch(() => null)
|
|
14
|
-
const client_token = await paypal.generateClientToken({ locale: "en_US" }).catch(() => "")
|
|
15
|
-
const cartId = (req.query?.cart_id as string) || ""
|
|
16
|
-
const query = req.scope.resolve("query")
|
|
17
|
-
let currency = normalizeCurrencyCode(
|
|
18
|
-
apiDetails?.apiDetails?.currency_code || process.env.PAYPAL_CURRENCY || "EUR"
|
|
19
|
-
)
|
|
20
|
-
if (cartId) {
|
|
21
|
-
const { data: carts } = await query.graph({
|
|
22
|
-
entity: "cart",
|
|
23
|
-
fields: ["id", "currency_code", "region.currency_code"],
|
|
24
|
-
filters: { id: cartId },
|
|
25
|
-
})
|
|
26
|
-
const cart = carts?.[0]
|
|
27
|
-
if (cart) {
|
|
28
|
-
currency = normalizeCurrencyCode(
|
|
29
|
-
cart.region?.currency_code || cart.currency_code || currency
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
const compatibility = getPayPalCurrencyCompatibility({
|
|
34
|
-
currencyCode: currency,
|
|
35
|
-
paypalCurrencyOverride:
|
|
36
|
-
apiDetails?.apiDetails?.currency_code || process.env.PAYPAL_CURRENCY,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
// Read settings so frontend SDK mirrors admin controls.
|
|
40
|
-
const settings = await paypal.getSettings().catch(() => ({}))
|
|
41
|
-
const data =
|
|
42
|
-
settings && typeof settings === "object" && "data" in settings
|
|
43
|
-
? ((settings as any).data || {})
|
|
44
|
-
: {}
|
|
45
|
-
|
|
46
|
-
const additionalSettings =
|
|
47
|
-
data && typeof data === "object"
|
|
48
|
-
? ((data as Record<string, any>).additional_settings || {})
|
|
49
|
-
: {}
|
|
50
|
-
|
|
51
|
-
const paypalSettings =
|
|
52
|
-
data && typeof data === "object"
|
|
53
|
-
? ((data as Record<string, any>).paypal_settings || {})
|
|
54
|
-
: {}
|
|
55
|
-
|
|
56
|
-
const paymentAction =
|
|
57
|
-
typeof additionalSettings.paymentAction === "string"
|
|
58
|
-
? additionalSettings.paymentAction
|
|
59
|
-
: "capture"
|
|
60
|
-
|
|
61
|
-
// P1 — enforce disable at API level: return 403 when admin disables PayPal wallet
|
|
62
|
-
if (paypalSettings.enabled === false) {
|
|
63
|
-
return res.status(403).json({ message: "PayPal is currently disabled." })
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// P2 — read advanced card payments settings
|
|
68
|
-
const advancedCardSettings =
|
|
69
|
-
data && typeof data === "object"
|
|
70
|
-
? ((data as Record<string, any>).advanced_card_payments || {})
|
|
71
|
-
: {}
|
|
72
|
-
|
|
73
|
-
const cardEnabled: boolean = advancedCardSettings.enabled !== false
|
|
74
|
-
|
|
75
|
-
const cardThreeDS =
|
|
76
|
-
typeof advancedCardSettings.threeDS === "string"
|
|
77
|
-
? advancedCardSettings.threeDS
|
|
78
|
-
: "when_required"
|
|
79
|
-
|
|
80
|
-
return res.json({
|
|
81
|
-
environment: creds.environment,
|
|
82
|
-
client_id: creds.client_id,
|
|
83
|
-
currency: compatibility.currency,
|
|
84
|
-
currency_supported: compatibility.supported,
|
|
85
|
-
currency_errors: compatibility.errors,
|
|
86
|
-
supported_currencies: getPayPalSupportedCurrencies(),
|
|
87
|
-
client_token,
|
|
88
|
-
intent: paymentAction,
|
|
89
|
-
paypal_enabled: paypalSettings.enabled ?? true,
|
|
90
|
-
paypal_title: paypalSettings.title || "PayPal",
|
|
91
|
-
card_enabled: cardEnabled,
|
|
92
|
-
card_title: advancedCardSettings.title || "Credit or Debit Card",
|
|
93
|
-
card_three_ds: cardThreeDS, // ← added
|
|
94
|
-
button_color: paypalSettings.buttonColor || "gold",
|
|
95
|
-
button_shape: paypalSettings.buttonShape || "rect",
|
|
96
|
-
button_width: paypalSettings.buttonWidth || "responsive",
|
|
97
|
-
button_height: paypalSettings.buttonHeight ?? 45,
|
|
98
|
-
button_label: paypalSettings.buttonLabel || "paypal",
|
|
99
|
-
})
|
|
100
|
-
} catch (e: any) {
|
|
101
|
-
return res.status(500).json({ message: e?.message || "Failed to load PayPal config" })
|
|
102
|
-
}
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
3
|
+
import {
|
|
4
|
+
getPayPalCurrencyCompatibility,
|
|
5
|
+
getPayPalSupportedCurrencies,
|
|
6
|
+
normalizeCurrencyCode,
|
|
7
|
+
} from "../../../../modules/paypal/utils/currencies"
|
|
8
|
+
|
|
9
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
10
|
+
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
11
|
+
try {
|
|
12
|
+
const creds = await paypal.getActiveCredentials()
|
|
13
|
+
const apiDetails = await paypal.getApiDetails().catch(() => null)
|
|
14
|
+
const client_token = await paypal.generateClientToken({ locale: "en_US" }).catch(() => "")
|
|
15
|
+
const cartId = (req.query?.cart_id as string) || ""
|
|
16
|
+
const query = req.scope.resolve("query")
|
|
17
|
+
let currency = normalizeCurrencyCode(
|
|
18
|
+
apiDetails?.apiDetails?.currency_code || process.env.PAYPAL_CURRENCY || "EUR"
|
|
19
|
+
)
|
|
20
|
+
if (cartId) {
|
|
21
|
+
const { data: carts } = await query.graph({
|
|
22
|
+
entity: "cart",
|
|
23
|
+
fields: ["id", "currency_code", "region.currency_code"],
|
|
24
|
+
filters: { id: cartId },
|
|
25
|
+
})
|
|
26
|
+
const cart = carts?.[0]
|
|
27
|
+
if (cart) {
|
|
28
|
+
currency = normalizeCurrencyCode(
|
|
29
|
+
cart.region?.currency_code || cart.currency_code || currency
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const compatibility = getPayPalCurrencyCompatibility({
|
|
34
|
+
currencyCode: currency,
|
|
35
|
+
paypalCurrencyOverride:
|
|
36
|
+
apiDetails?.apiDetails?.currency_code || process.env.PAYPAL_CURRENCY,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Read settings so frontend SDK mirrors admin controls.
|
|
40
|
+
const settings = await paypal.getSettings().catch(() => ({}))
|
|
41
|
+
const data =
|
|
42
|
+
settings && typeof settings === "object" && "data" in settings
|
|
43
|
+
? ((settings as any).data || {})
|
|
44
|
+
: {}
|
|
45
|
+
|
|
46
|
+
const additionalSettings =
|
|
47
|
+
data && typeof data === "object"
|
|
48
|
+
? ((data as Record<string, any>).additional_settings || {})
|
|
49
|
+
: {}
|
|
50
|
+
|
|
51
|
+
const paypalSettings =
|
|
52
|
+
data && typeof data === "object"
|
|
53
|
+
? ((data as Record<string, any>).paypal_settings || {})
|
|
54
|
+
: {}
|
|
55
|
+
|
|
56
|
+
const paymentAction =
|
|
57
|
+
typeof additionalSettings.paymentAction === "string"
|
|
58
|
+
? additionalSettings.paymentAction
|
|
59
|
+
: "capture"
|
|
60
|
+
|
|
61
|
+
// P1 — enforce disable at API level: return 403 when admin disables PayPal wallet
|
|
62
|
+
if (paypalSettings.enabled === false) {
|
|
63
|
+
return res.status(403).json({ message: "PayPal is currently disabled." })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
// P2 — read advanced card payments settings
|
|
68
|
+
const advancedCardSettings =
|
|
69
|
+
data && typeof data === "object"
|
|
70
|
+
? ((data as Record<string, any>).advanced_card_payments || {})
|
|
71
|
+
: {}
|
|
72
|
+
|
|
73
|
+
const cardEnabled: boolean = advancedCardSettings.enabled !== false
|
|
74
|
+
|
|
75
|
+
const cardThreeDS =
|
|
76
|
+
typeof advancedCardSettings.threeDS === "string"
|
|
77
|
+
? advancedCardSettings.threeDS
|
|
78
|
+
: "when_required"
|
|
79
|
+
|
|
80
|
+
return res.json({
|
|
81
|
+
environment: creds.environment,
|
|
82
|
+
client_id: creds.client_id,
|
|
83
|
+
currency: compatibility.currency,
|
|
84
|
+
currency_supported: compatibility.supported,
|
|
85
|
+
currency_errors: compatibility.errors,
|
|
86
|
+
supported_currencies: getPayPalSupportedCurrencies(),
|
|
87
|
+
client_token,
|
|
88
|
+
intent: paymentAction,
|
|
89
|
+
paypal_enabled: paypalSettings.enabled ?? true,
|
|
90
|
+
paypal_title: paypalSettings.title || "PayPal",
|
|
91
|
+
card_enabled: cardEnabled,
|
|
92
|
+
card_title: advancedCardSettings.title || "Credit or Debit Card",
|
|
93
|
+
card_three_ds: cardThreeDS, // ← added
|
|
94
|
+
button_color: paypalSettings.buttonColor || "gold",
|
|
95
|
+
button_shape: paypalSettings.buttonShape || "rect",
|
|
96
|
+
button_width: paypalSettings.buttonWidth || "responsive",
|
|
97
|
+
button_height: paypalSettings.buttonHeight ?? 45,
|
|
98
|
+
button_label: paypalSettings.buttonLabel || "paypal",
|
|
99
|
+
})
|
|
100
|
+
} catch (e: any) {
|
|
101
|
+
return res.status(500).json({ message: e?.message || "Failed to load PayPal config" })
|
|
102
|
+
}
|
|
103
103
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
2
|
import { randomUUID } from "crypto"
|
|
3
|
-
import { getCurrencyExponent
|
|
3
|
+
import { getCurrencyExponent } from "../../../../modules/paypal/utils/amounts"
|
|
4
4
|
import {
|
|
5
5
|
assertPayPalCurrencySupported,
|
|
6
6
|
normalizeCurrencyCode,
|
|
@@ -101,117 +101,6 @@ function resolveCancelUrl(req: MedusaRequest) {
|
|
|
101
101
|
return `${configured.replace(/\/$/, "")}/cart`
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// buildPayPalLineItems
|
|
106
|
-
//
|
|
107
|
-
// This function mirrors the WooCommerce PayPal plugin reconciliation logic:
|
|
108
|
-
//
|
|
109
|
-
// 1. Convert each item's line_subtotal from MINOR units (cents) to MAJOR
|
|
110
|
-
// units (euros/dollars) using the currency exponent factor.
|
|
111
|
-
// e.g. 1000 cents ÷ 100 = €10.00
|
|
112
|
-
//
|
|
113
|
-
// 2. Compute unit_amount = line_subtotal_major / quantity (pre-discount
|
|
114
|
-
// price per unit, rounded to currency decimal places).
|
|
115
|
-
//
|
|
116
|
-
// 3. Sum all (unit_amount × quantity) — this is "roundedItemSum".
|
|
117
|
-
//
|
|
118
|
-
// 4. Compare roundedItemSum with subtotalMajor (cart.subtotal in major
|
|
119
|
-
// units, already correct from Medusa). If there is any rounding
|
|
120
|
-
// discrepancy (e.g. ±0.01), insert a single "Line Item Amount Offset"
|
|
121
|
-
// item worth exactly `diff` to absorb it.
|
|
122
|
-
// After this: sum(items) === subtotalMajor exactly.
|
|
123
|
-
//
|
|
124
|
-
// 5. breakdown.item_total is ALWAYS set to subtotalMajor — never modified.
|
|
125
|
-
// The offset item ensures the items array sums to match it.
|
|
126
|
-
//
|
|
127
|
-
// 6. If the final breakdownSum (item_total + shipping + tax - discount)
|
|
128
|
-
// does not match cart.total, absorb the gap via shipping_discount
|
|
129
|
-
// (if gap < 0) or by adding to tax_total (if gap > 0).
|
|
130
|
-
// This guarantees the PayPal order total always matches cart.total exactly.
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
function buildPayPalLineItems(
|
|
133
|
-
lineItems: any[],
|
|
134
|
-
subtotalMajor: number,
|
|
135
|
-
currency: string,
|
|
136
|
-
exponent: number,
|
|
137
|
-
sendItemDetails: boolean
|
|
138
|
-
): { items: any[]; adjustedItemTotal: number } {
|
|
139
|
-
if (!sendItemDetails || lineItems.length === 0) {
|
|
140
|
-
return { items: [], adjustedItemTotal: subtotalMajor }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// factor = 100 for EUR/USD, 1 for JPY, 1000 for KWD, etc.
|
|
144
|
-
const factor = Math.pow(10, exponent)
|
|
145
|
-
|
|
146
|
-
// Step 1 & 2: Build raw items with proper unit amounts
|
|
147
|
-
const rawItems = lineItems
|
|
148
|
-
.map((item: any) => {
|
|
149
|
-
const quantity = Number(item?.quantity || 0)
|
|
150
|
-
if (!quantity || Number.isNaN(quantity)) return null
|
|
151
|
-
|
|
152
|
-
// item.subtotal is in MINOR units (cents) — must divide by factor
|
|
153
|
-
const lineSubtotalMinor = Number(
|
|
154
|
-
item?.subtotal ?? Number(item?.unit_price || 0) * quantity
|
|
155
|
-
)
|
|
156
|
-
const lineSubtotalMajor = lineSubtotalMinor / factor
|
|
157
|
-
|
|
158
|
-
// unit price per item, rounded to currency decimal places
|
|
159
|
-
const unitAmount = parseFloat((lineSubtotalMajor / quantity).toFixed(exponent))
|
|
160
|
-
|
|
161
|
-
if (Number.isNaN(unitAmount)) return null
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
quantity,
|
|
165
|
-
unitAmount,
|
|
166
|
-
paypalItem: {
|
|
167
|
-
name: String(item?.title || "Item").slice(0, 127),
|
|
168
|
-
quantity: String(quantity),
|
|
169
|
-
unit_amount: {
|
|
170
|
-
currency_code: currency,
|
|
171
|
-
value: unitAmount.toFixed(exponent),
|
|
172
|
-
},
|
|
173
|
-
},
|
|
174
|
-
}
|
|
175
|
-
})
|
|
176
|
-
.filter(Boolean) as Array<{ quantity: number; unitAmount: number; paypalItem: any }>
|
|
177
|
-
|
|
178
|
-
if (rawItems.length === 0) {
|
|
179
|
-
return { items: [], adjustedItemTotal: subtotalMajor }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Step 3: Sum of (unit_amount × quantity) after rounding
|
|
183
|
-
const roundedItemSum = rawItems.reduce(
|
|
184
|
-
(sum, item) => sum + item.unitAmount * item.quantity,
|
|
185
|
-
0
|
|
186
|
-
)
|
|
187
|
-
const roundedItemSumFixed = parseFloat(roundedItemSum.toFixed(exponent))
|
|
188
|
-
|
|
189
|
-
// Step 4: Calculate rounding difference
|
|
190
|
-
// diff = how much to add as an offset item so that:
|
|
191
|
-
// roundedItemSumFixed + diff === subtotalMajor
|
|
192
|
-
const diff = parseFloat((subtotalMajor - roundedItemSumFixed).toFixed(exponent))
|
|
193
|
-
|
|
194
|
-
const finalItems = rawItems.map((item) => item.paypalItem)
|
|
195
|
-
|
|
196
|
-
if (Math.abs(diff) > 0.000001) {
|
|
197
|
-
// Insert a tiny offset item to absorb the rounding gap.
|
|
198
|
-
// After this: sum(finalItems unit_amount × quantity) === subtotalMajor exactly.
|
|
199
|
-
finalItems.push({
|
|
200
|
-
name: "Line Item Amount Offset",
|
|
201
|
-
quantity: "1",
|
|
202
|
-
unit_amount: {
|
|
203
|
-
currency_code: currency,
|
|
204
|
-
value: diff.toFixed(exponent),
|
|
205
|
-
},
|
|
206
|
-
})
|
|
207
|
-
// NOTE: adjustedItemTotal stays as subtotalMajor — do NOT add diff here.
|
|
208
|
-
// The offset item brings the items sum UP to subtotalMajor.
|
|
209
|
-
// Adding diff to adjustedItemTotal would cause a double-count.
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return { items: finalItems, adjustedItemTotal: subtotalMajor }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
104
|
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
216
105
|
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
217
106
|
let debugId: string | null = null
|
|
@@ -230,12 +119,9 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
230
119
|
return res.json({ id: existingOrderId })
|
|
231
120
|
}
|
|
232
121
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// EXCEPT item-level fields (item.unit_price, item.subtotal, item.total)
|
|
237
|
-
// which are in MINOR units (cents). This is the root cause of the bug.
|
|
238
|
-
// -------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* ✅ Medusa v2 cart retrieval via Query Graph
|
|
124
|
+
*/
|
|
239
125
|
const query = req.scope.resolve("query")
|
|
240
126
|
|
|
241
127
|
const { data } = await query.graph({
|
|
@@ -289,11 +175,10 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
289
175
|
: "when_required"
|
|
290
176
|
|
|
291
177
|
const threeDsMethod: string | null = isCardPayment
|
|
292
|
-
? threeDsRaw === "always"
|
|
293
|
-
|
|
294
|
-
|
|
178
|
+
? (threeDsRaw === "always"
|
|
179
|
+
? "SCA_ALWAYS"
|
|
180
|
+
: "SCA_WHEN_REQUIRED")
|
|
295
181
|
: null
|
|
296
|
-
|
|
297
182
|
const configuredCurrency =
|
|
298
183
|
typeof apiDetails.currency_code === "string"
|
|
299
184
|
? normalizeCurrencyCode(apiDetails.currency_code)
|
|
@@ -308,18 +193,7 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
308
193
|
})
|
|
309
194
|
|
|
310
195
|
const exponent = getCurrencyExponent(currency)
|
|
311
|
-
|
|
312
|
-
// -------------------------------------------------------------------------
|
|
313
|
-
// Cart-level totals — these are already in MAJOR units (euros/dollars)
|
|
314
|
-
// e.g. cart.total = 20 means €20.00
|
|
315
|
-
// -------------------------------------------------------------------------
|
|
316
196
|
const totalMajor = Number(cart.total || 0)
|
|
317
|
-
const subtotalMajor = Number(cart.subtotal || 0)
|
|
318
|
-
const shippingMajor = Number(cart.shipping_total || 0)
|
|
319
|
-
const taxMajor = Number(cart.tax_total || 0)
|
|
320
|
-
const discountMajor = Number(cart.discount_total || 0)
|
|
321
|
-
const giftCardMajor = Number(cart.gift_card_total || 0)
|
|
322
|
-
|
|
323
197
|
const value = totalMajor.toFixed(exponent)
|
|
324
198
|
|
|
325
199
|
const paymentActionRaw =
|
|
@@ -327,7 +201,6 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
327
201
|
? additionalSettings.paymentAction
|
|
328
202
|
: "capture"
|
|
329
203
|
const paymentAction = paymentActionRaw === "authorize" ? "AUTHORIZE" : "CAPTURE"
|
|
330
|
-
|
|
331
204
|
const brandName =
|
|
332
205
|
typeof additionalSettings.brandName === "string"
|
|
333
206
|
? additionalSettings.brandName
|
|
@@ -358,15 +231,14 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
358
231
|
? additionalSettings.invoicePrefix
|
|
359
232
|
: ""
|
|
360
233
|
const invoiceId = `${invoicePrefix}${cart.id}`.trim() || cart.id
|
|
361
|
-
|
|
362
234
|
const returnUrl =
|
|
363
|
-
typeof apiDetails.storefront_url === "string" && apiDetails.storefront_url.trim()
|
|
235
|
+
(typeof apiDetails.storefront_url === "string" && apiDetails.storefront_url.trim()
|
|
364
236
|
? `${apiDetails.storefront_url.replace(/\/$/, "")}/checkout`
|
|
365
|
-
: resolveReturnUrl(req)
|
|
237
|
+
: resolveReturnUrl(req))
|
|
366
238
|
const cancelUrl =
|
|
367
|
-
typeof apiDetails.storefront_url === "string" && apiDetails.storefront_url.trim()
|
|
239
|
+
(typeof apiDetails.storefront_url === "string" && apiDetails.storefront_url.trim()
|
|
368
240
|
? `${apiDetails.storefront_url.replace(/\/$/, "")}/cart`
|
|
369
|
-
: resolveCancelUrl(req)
|
|
241
|
+
: resolveCancelUrl(req))
|
|
370
242
|
|
|
371
243
|
const applicationContext: Record<string, any> = {
|
|
372
244
|
...(brandName ? { brand_name: brandName } : {}),
|
|
@@ -376,26 +248,72 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
376
248
|
...(cancelUrl ? { cancel_url: cancelUrl } : {}),
|
|
377
249
|
}
|
|
378
250
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
251
|
+
const subtotalMajor = Number(cart.subtotal || 0)
|
|
252
|
+
const shippingMajor = Number(cart.shipping_total || 0)
|
|
253
|
+
const taxMajor = Number(cart.tax_total || 0)
|
|
254
|
+
const discountMajor = Number(cart.discount_total || 0)
|
|
255
|
+
const giftCardMajor = Number(cart.gift_card_total || 0)
|
|
383
256
|
const lineItems = Array.isArray((cart as any).items) ? (cart as any).items : []
|
|
257
|
+
// factor converts MINOR units (cents) → MAJOR units (euros)
|
|
258
|
+
// e.g. item.subtotal=1000, factor=100 → €10.00
|
|
259
|
+
const factor = Math.pow(10, exponent)
|
|
260
|
+
|
|
261
|
+
const purchaseItemsRaw = sendItemDetails
|
|
262
|
+
? lineItems
|
|
263
|
+
.map((item: any) => {
|
|
264
|
+
const quantity = Number(item?.quantity || 0)
|
|
265
|
+
// item.subtotal is in MINOR units (cents) — divide by factor to get euros
|
|
266
|
+
const lineSubtotalMinor = Number(
|
|
267
|
+
item?.subtotal ?? (Number(item?.unit_price || 0) * quantity)
|
|
268
|
+
)
|
|
269
|
+
const lineSubtotalMajor = lineSubtotalMinor / factor
|
|
270
|
+
const unitAmount =
|
|
271
|
+
quantity > 0 ? parseFloat((lineSubtotalMajor / quantity).toFixed(exponent)) : 0
|
|
272
|
+
|
|
273
|
+
if (!quantity || Number.isNaN(quantity) || Number.isNaN(unitAmount)) {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
quantity,
|
|
279
|
+
unitAmount,
|
|
280
|
+
paypalItem: {
|
|
281
|
+
name: String(item?.title || "Item").slice(0, 127),
|
|
282
|
+
quantity: String(Math.max(1, quantity)),
|
|
283
|
+
unit_amount: {
|
|
284
|
+
currency_code: currency,
|
|
285
|
+
value: unitAmount.toFixed(exponent),
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
.filter(Boolean)
|
|
291
|
+
: []
|
|
384
292
|
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
currency,
|
|
389
|
-
exponent,
|
|
390
|
-
sendItemDetails
|
|
293
|
+
const roundedItemSum = purchaseItemsRaw.reduce(
|
|
294
|
+
(sum: number, item: any) => sum + item.unitAmount * item.quantity,
|
|
295
|
+
0
|
|
391
296
|
)
|
|
297
|
+
const roundedItemSumFixed = parseFloat(roundedItemSum.toFixed(exponent))
|
|
298
|
+
const diff = parseFloat((subtotalMajor - roundedItemSumFixed).toFixed(exponent))
|
|
299
|
+
|
|
300
|
+
const finalPurchaseItems = purchaseItemsRaw.map((item: any) => item.paypalItem)
|
|
301
|
+
// adjustedItemTotal stays as subtotalMajor — the offset item absorbs the rounding gap
|
|
302
|
+
// so that sum(unit_amount × quantity) === subtotalMajor exactly
|
|
303
|
+
const adjustedItemTotal = subtotalMajor
|
|
304
|
+
|
|
305
|
+
if (Math.abs(diff) > 0.000001 && sendItemDetails && finalPurchaseItems.length > 0) {
|
|
306
|
+
finalPurchaseItems.push({
|
|
307
|
+
name: "Line Item Amount Offset",
|
|
308
|
+
quantity: "1",
|
|
309
|
+
unit_amount: {
|
|
310
|
+
currency_code: currency,
|
|
311
|
+
value: diff.toFixed(exponent),
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
}
|
|
392
315
|
|
|
393
|
-
// -------------------------------------------------------------------------
|
|
394
|
-
// Build the breakdown object.
|
|
395
|
-
// PayPal rule: item_total + shipping + tax_total - discount - shipping_discount === value
|
|
396
|
-
// -------------------------------------------------------------------------
|
|
397
316
|
const breakdown: Record<string, any> = {}
|
|
398
|
-
|
|
399
317
|
if (adjustedItemTotal > 0) {
|
|
400
318
|
breakdown.item_total = {
|
|
401
319
|
currency_code: currency,
|
|
@@ -415,8 +333,6 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
415
333
|
}
|
|
416
334
|
}
|
|
417
335
|
|
|
418
|
-
// Discounts and gift cards — only include if we are also sending items,
|
|
419
|
-
// otherwise PayPal rejects breakdown with discount but no items.
|
|
420
336
|
const discountValue = discountMajor + giftCardMajor
|
|
421
337
|
if (discountValue > 0 && finalPurchaseItems.length > 0) {
|
|
422
338
|
breakdown.discount = {
|
|
@@ -425,12 +341,6 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
425
341
|
}
|
|
426
342
|
}
|
|
427
343
|
|
|
428
|
-
// -------------------------------------------------------------------------
|
|
429
|
-
// Final reconciliation: ensure breakdown sums exactly to cart total.
|
|
430
|
-
// If there is a gap (can happen due to rounding across multiple fields),
|
|
431
|
-
// absorb it via shipping_discount (if we over-counted) or tax_total
|
|
432
|
-
// (if we under-counted). This is exactly what WooCommerce does.
|
|
433
|
-
// -------------------------------------------------------------------------
|
|
434
344
|
const breakdownSum = parseFloat(
|
|
435
345
|
(adjustedItemTotal + shippingMajor + taxMajor - discountValue).toFixed(exponent)
|
|
436
346
|
)
|
|
@@ -439,14 +349,13 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
439
349
|
const gap = parseFloat((totalMajor - breakdownSum).toFixed(exponent))
|
|
440
350
|
|
|
441
351
|
if (gap > 0) {
|
|
442
|
-
// We under-counted — add the gap to tax_total
|
|
443
|
-
const existingTax = Number(breakdown.tax_total?.value || 0)
|
|
444
352
|
breakdown.tax_total = {
|
|
445
353
|
currency_code: currency,
|
|
446
|
-
value: parseFloat(
|
|
354
|
+
value: parseFloat(
|
|
355
|
+
((Number(breakdown.tax_total?.value || 0) + gap).toFixed(exponent))
|
|
356
|
+
).toFixed(exponent),
|
|
447
357
|
}
|
|
448
358
|
} else {
|
|
449
|
-
// We over-counted — add shipping_discount to bring total down
|
|
450
359
|
breakdown.shipping_discount = {
|
|
451
360
|
currency_code: currency,
|
|
452
361
|
value: Math.abs(gap).toFixed(exponent),
|
|
@@ -462,14 +371,6 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
462
371
|
|
|
463
372
|
const requestId = resolveIdempotencyKey(req, "create-order", `pp-create-${cart.id}`)
|
|
464
373
|
|
|
465
|
-
// Log what we are sending to PayPal for debugging
|
|
466
|
-
console.info("[PayPal] create-order payload", {
|
|
467
|
-
cart_id: cart.id,
|
|
468
|
-
value,
|
|
469
|
-
breakdown,
|
|
470
|
-
item_count: finalPurchaseItems.length,
|
|
471
|
-
})
|
|
472
|
-
|
|
473
374
|
const ppResp = await fetch(`${base}/v2/checkout/orders`, {
|
|
474
375
|
method: "POST",
|
|
475
376
|
headers: {
|
|
@@ -526,7 +427,7 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
526
427
|
|
|
527
428
|
await attachPayPalOrderToSession(req, cart.id, order.id)
|
|
528
429
|
|
|
529
|
-
console.info("[PayPal] create-order
|
|
430
|
+
console.info("[PayPal] create-order", {
|
|
530
431
|
cart_id: cart.id,
|
|
531
432
|
order_id: order.id,
|
|
532
433
|
request_id: requestId,
|
|
@@ -558,4 +459,4 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
558
459
|
: 500
|
|
559
460
|
return res.status(status).json({ message })
|
|
560
461
|
}
|
|
561
|
-
}
|
|
462
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
-
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
3
|
-
|
|
4
|
-
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
5
|
-
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
6
|
-
try {
|
|
7
|
-
const settings = await paypal.getSettings()
|
|
8
|
-
const data = (settings?.data || {}) as Record<string, any>
|
|
9
|
-
const additionalSettings = (data.additional_settings || {}) as Record<string, any>
|
|
10
|
-
const advancedCard = (data.advanced_card_payments || {}) as Record<string, any>
|
|
11
|
-
|
|
12
|
-
return res.json({
|
|
13
|
-
paymentAction: additionalSettings.paymentAction === "authorize" ? "authorize" : "capture",
|
|
14
|
-
advancedCardEnabled: advancedCard.enabled === true,
|
|
15
|
-
})
|
|
16
|
-
} catch (e: any) {
|
|
17
|
-
return res.status(500).json({ message: e?.message || "Failed to load PayPal settings" })
|
|
18
|
-
}
|
|
19
|
-
}
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
3
|
+
|
|
4
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
5
|
+
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
6
|
+
try {
|
|
7
|
+
const settings = await paypal.getSettings()
|
|
8
|
+
const data = (settings?.data || {}) as Record<string, any>
|
|
9
|
+
const additionalSettings = (data.additional_settings || {}) as Record<string, any>
|
|
10
|
+
const advancedCard = (data.advanced_card_payments || {}) as Record<string, any>
|
|
11
|
+
|
|
12
|
+
return res.json({
|
|
13
|
+
paymentAction: additionalSettings.paymentAction === "authorize" ? "authorize" : "capture",
|
|
14
|
+
advancedCardEnabled: advancedCard.enabled === true,
|
|
15
|
+
})
|
|
16
|
+
} catch (e: any) {
|
|
17
|
+
return res.status(500).json({ message: e?.message || "Failed to load PayPal settings" })
|
|
18
|
+
}
|
|
19
|
+
}
|