@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.
Files changed (70) hide show
  1. package/.medusa/server/src/admin/index.js +7 -7
  2. package/.medusa/server/src/admin/index.mjs +7 -7
  3. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/create-order/route.js +62 -139
  5. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  6. package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
  7. package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
  8. package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
  9. package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
  10. package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
  11. package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
  12. package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
  13. package/README.md +142 -142
  14. package/package.json +75 -75
  15. package/src/admin/index.ts +7 -7
  16. package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
  17. package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
  18. package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
  19. package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
  20. package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
  21. package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
  22. package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
  23. package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
  24. package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
  25. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
  26. package/src/api/admin/paypal/disconnect/route.ts +8 -8
  27. package/src/api/admin/paypal/environment/route.ts +25 -25
  28. package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
  29. package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
  30. package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
  31. package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
  32. package/src/api/admin/paypal/save-credentials/route.ts +14 -14
  33. package/src/api/admin/paypal/settings/route.ts +14 -14
  34. package/src/api/admin/paypal/status/route.ts +12 -12
  35. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
  36. package/src/api/store/paypal/capture-order/route.ts +276 -276
  37. package/src/api/store/paypal/config/route.ts +102 -102
  38. package/src/api/store/paypal/create-order/route.ts +77 -176
  39. package/src/api/store/paypal/settings/route.ts +19 -19
  40. package/src/api/store/paypal/webhook/route.ts +246 -246
  41. package/src/api/store/paypal-complete/route.ts +75 -75
  42. package/src/jobs/paypal-reconcile.ts +112 -112
  43. package/src/jobs/paypal-webhook-retry.ts +85 -85
  44. package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
  45. package/src/modules/paypal/index.ts +8 -8
  46. package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
  47. package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
  48. package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
  49. package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
  50. package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
  51. package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
  52. package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
  53. package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
  54. package/src/modules/paypal/models/paypal_connection.ts +21 -21
  55. package/src/modules/paypal/models/paypal_metric.ts +9 -9
  56. package/src/modules/paypal/models/paypal_settings.ts +8 -8
  57. package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
  58. package/src/modules/paypal/payment-provider/README.md +22 -22
  59. package/src/modules/paypal/payment-provider/card-service.ts +760 -760
  60. package/src/modules/paypal/payment-provider/index.ts +19 -19
  61. package/src/modules/paypal/payment-provider/service.ts +1121 -1121
  62. package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
  63. package/src/modules/paypal/service.ts +1247 -1247
  64. package/src/modules/paypal/types/config.ts +47 -47
  65. package/src/modules/paypal/utils/amounts.ts +41 -41
  66. package/src/modules/paypal/utils/crypto.ts +51 -51
  67. package/src/modules/paypal/utils/currencies.ts +84 -84
  68. package/src/modules/paypal/utils/paypal-auth.ts +32 -32
  69. package/src/modules/paypal/utils/provider-ids.ts +15 -15
  70. 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, formatAmountForPayPal } from "../../../../modules/paypal/utils/amounts"
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
- // Fetch cart via Query Graph
235
- // All monetary fields returned by Medusa are in MAJOR units (e.g. euros)
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
- ? "SCA_ALWAYS"
294
- : "SCA_WHEN_REQUIRED"
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
- // Build line items with correct minor→major conversion and reconciliation.
381
- // See buildPayPalLineItems() above for full explanation.
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 { items: finalPurchaseItems, adjustedItemTotal } = buildPayPalLineItems(
386
- lineItems,
387
- subtotalMajor,
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((existingTax + gap).toFixed(exponent)).toFixed(exponent),
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 success", {
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
+ }