@easypayment/medusa-paypal 0.2.4 → 0.2.6

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.
@@ -1,270 +1,284 @@
1
- import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
- import { randomUUID } from "crypto"
3
- import type PayPalModuleService from "../../../../modules/paypal/service"
4
- import { isPayPalProviderId } from "../../../../modules/paypal/utils/provider-ids"
5
-
6
- type Body = {
7
- cart_id: string
8
- order_id: string
9
- }
10
-
11
- async function getPayPalApiBase(environment: string) {
12
- return environment === "live"
13
- ? "https://api-m.paypal.com"
14
- : "https://api-m.sandbox.paypal.com"
15
- }
16
-
17
- async function getPayPalAccessToken(opts: {
18
- environment: string
19
- client_id: string
20
- client_secret: string
21
- }) {
22
- const base = await getPayPalApiBase(opts.environment)
23
- const auth = Buffer.from(`${opts.client_id}:${opts.client_secret}`).toString("base64")
24
-
25
- const resp = await fetch(`${base}/v1/oauth2/token`, {
26
- method: "POST",
27
- headers: {
28
- Authorization: `Basic ${auth}`,
29
- "Content-Type": "application/x-www-form-urlencoded",
30
- },
31
- body: "grant_type=client_credentials",
32
- })
33
-
34
- const text = await resp.text()
35
- if (!resp.ok) {
36
- throw new Error(`PayPal token error (${resp.status}): ${text}`)
37
- }
38
-
39
- const json = JSON.parse(text)
40
- return { accessToken: String(json.access_token), base }
41
- }
42
-
43
- function resolveIdempotencyKey(req: MedusaRequest, suffix: string, fallback: string) {
44
- const header =
45
- req.headers["idempotency-key"] ||
46
- req.headers["Idempotency-Key"] ||
47
- req.headers["x-idempotency-key"] ||
48
- req.headers["X-Idempotency-Key"]
49
- const key = Array.isArray(header) ? header[0] : header
50
- if (key && String(key).trim()) {
51
- return `${String(key).trim()}-${suffix}`
52
- }
53
- return fallback || `pp-${suffix}-${randomUUID()}`
54
- }
55
-
56
- async function attachPayPalCaptureToSession(
57
- req: MedusaRequest,
58
- cartId: string,
59
- orderId: string,
60
- capture: any
61
- ) {
62
- try {
63
- const paymentCollectionService = req.scope.resolve("payment_collection") as any
64
- const paymentSessionService = req.scope.resolve("payment_session") as any
65
-
66
- const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
67
- if (!pc?.id) {
68
- return
69
- }
70
-
71
- const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
72
- const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
73
- if (!paypalSession) {
74
- return
75
- }
76
-
77
- await paymentSessionService.update(paypalSession.id, {
78
- status: "captured",
79
- data: {
80
- ...(paypalSession.data || {}),
81
- paypal: {
82
- ...((paypalSession.data || {}).paypal || {}),
83
- order_id: orderId,
84
- capture_id: capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id,
85
- capture,
86
- },
87
- },
88
- })
89
- } catch {
90
- // ignore
91
- }
92
- }
93
-
94
- async function attachPayPalAuthorizationToSession(
95
- req: MedusaRequest,
96
- cartId: string,
97
- orderId: string,
98
- authorization: any
99
- ) {
100
- try {
101
- const paymentCollectionService = req.scope.resolve("payment_collection") as any
102
- const paymentSessionService = req.scope.resolve("payment_session") as any
103
-
104
- const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
105
- if (!pc?.id) {
106
- return
107
- }
108
-
109
- const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
110
- const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
111
- if (!paypalSession) {
112
- return
113
- }
114
-
115
- await paymentSessionService.update(paypalSession.id, {
116
- status: "authorized",
117
- data: {
118
- ...(paypalSession.data || {}),
119
- paypal: {
120
- ...((paypalSession.data || {}).paypal || {}),
121
- order_id: orderId,
122
- authorization_id:
123
- authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id,
124
- authorization,
125
- },
126
- },
127
- })
128
- } catch {
129
- // ignore
130
- }
131
- }
132
- async function getExistingCapture(
133
- req: MedusaRequest,
134
- cartId: string,
135
- orderId: string
136
- ) {
137
- try {
138
- const paymentCollectionService = req.scope.resolve("payment_collection") as any
139
- const paymentSessionService = req.scope.resolve("payment_session") as any
140
-
141
- const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
142
- if (!pc?.id) {
143
- return null
144
- }
145
-
146
- const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
147
- const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
148
- if (!paypalSession) {
149
- return null
150
- }
151
-
152
- const paypalData = (paypalSession.data || {}).paypal || {}
153
- const existingOrderId = String(paypalData.order_id || "")
154
- if (existingOrderId && existingOrderId !== orderId) {
155
- return null
156
- }
157
- if (paypalData.capture) {
158
- return paypalData.capture
159
- }
160
- if (paypalData.capture_id) {
161
- return { id: paypalData.capture_id }
162
- }
163
- return null
164
- } catch {
165
- return null
166
- }
167
- }
168
-
169
-
170
- export async function POST(req: MedusaRequest, res: MedusaResponse) {
171
- const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
172
- let debugId: string | null = null
173
-
174
- try {
175
- const body = (req.body || {}) as Body
176
- const cartId = body.cart_id
177
- const orderId = body.order_id
178
-
179
- if (!cartId || !orderId) {
180
- return res.status(400).json({ message: "cart_id and order_id are required" })
181
- }
182
-
183
- const existingCapture = await getExistingCapture(req, cartId, orderId)
184
- if (existingCapture) {
185
- return res.json({ capture: existingCapture })
186
- }
187
-
188
- const creds = await paypal.getActiveCredentials()
189
- const { accessToken, base } = await getPayPalAccessToken(creds)
190
- const settings = await paypal.getSettings().catch(() => ({}))
191
- const data =
192
- settings && typeof settings === "object" && "data" in settings
193
- ? ((settings as { data?: Record<string, any> }).data ?? {})
194
- : {}
195
- const additionalSettings = (data.additional_settings || {}) as Record<string, any>
196
- const paymentAction =
197
- typeof additionalSettings.paymentAction === "string"
198
- ? additionalSettings.paymentAction
199
- : "capture"
200
-
201
- const requestId = resolveIdempotencyKey(req, "capture-order", `pp-capture-${orderId}`)
202
- const endpoint =
203
- paymentAction === "authorize"
204
- ? `${base}/v2/checkout/orders/${orderId}/authorize`
205
- : `${base}/v2/checkout/orders/${orderId}/capture`
206
-
207
- const ppResp = await fetch(endpoint, {
208
- method: "POST",
209
- headers: {
210
- Authorization: `Bearer ${accessToken}`,
211
- "Content-Type": "application/json",
212
- "PayPal-Request-Id": requestId,
213
- },
214
- })
215
-
216
- const ppText = await ppResp.text()
217
- debugId = ppResp.headers.get("paypal-debug-id")
218
- if (!ppResp.ok) {
219
- throw new Error(
220
- `PayPal capture error (${ppResp.status}): ${ppText}${
221
- debugId ? ` debug_id=${debugId}` : ""
222
- }`
223
- )
224
- }
225
-
226
- const payload = JSON.parse(ppText)
227
- if (paymentAction === "authorize") {
228
- await attachPayPalAuthorizationToSession(req, cartId, orderId, payload)
229
- } else {
230
- await attachPayPalCaptureToSession(req, cartId, orderId, payload)
231
- }
232
-
233
- console.info("[PayPal] capture-order", {
234
- cart_id: cartId,
235
- order_id: orderId,
236
- request_id: requestId,
237
- debug_id: ppResp.headers.get("paypal-debug-id"),
238
- capture_id: payload?.id,
239
- })
240
- try {
241
- await paypal.recordMetric(
242
- paymentAction === "authorize" ? "authorize_order_success" : "capture_order_success"
243
- )
244
- } catch {
245
- // ignore metrics failures
246
- }
247
-
248
-
249
- // Clear storefront cart cookie (httpOnly) so user gets a fresh cart after successful payment
250
- // Note: works when storefront and backend share the same cookie domain (e.g. localhost)
251
- res.setHeader("Set-Cookie", "_medusa_cart_id=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict")
252
- return paymentAction === "authorize"
253
- ? res.json({ authorization: payload })
254
- : res.json({ capture: payload })
255
- } catch (e: any) {
256
- try {
257
- const body = (req.body || {}) as Body
258
- await paypal.recordAuditEvent("capture_order_failed", {
259
- cart_id: body.cart_id,
260
- order_id: body.order_id,
261
- debug_id: debugId,
262
- message: e?.message || String(e),
263
- })
264
- await paypal.recordMetric("capture_order_failed")
265
- } catch {
266
- // ignore audit logging failures
267
- }
268
- return res.status(500).json({ message: e?.message || "Failed to capture PayPal order" })
269
- }
270
- }
1
+ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { randomUUID } from "crypto"
3
+ import { Pool } from "pg"
4
+ import type PayPalModuleService from "../../../../modules/paypal/service"
5
+ import { isPayPalProviderId } from "../../../../modules/paypal/utils/provider-ids"
6
+
7
+ type Body = {
8
+ cart_id: string
9
+ order_id: string
10
+ }
11
+
12
+ async function getPayPalApiBase(environment: string) {
13
+ return environment === "live"
14
+ ? "https://api-m.paypal.com"
15
+ : "https://api-m.sandbox.paypal.com"
16
+ }
17
+
18
+ async function getPayPalAccessToken(opts: {
19
+ environment: string
20
+ client_id: string
21
+ client_secret: string
22
+ }) {
23
+ const base = await getPayPalApiBase(opts.environment)
24
+ const auth = Buffer.from(`${opts.client_id}:${opts.client_secret}`).toString("base64")
25
+
26
+ const resp = await fetch(`${base}/v1/oauth2/token`, {
27
+ method: "POST",
28
+ headers: {
29
+ Authorization: `Basic ${auth}`,
30
+ "Content-Type": "application/x-www-form-urlencoded",
31
+ },
32
+ body: "grant_type=client_credentials",
33
+ })
34
+
35
+ const text = await resp.text()
36
+ if (!resp.ok) {
37
+ throw new Error(`PayPal token error (${resp.status}): ${text}`)
38
+ }
39
+
40
+ const json = JSON.parse(text)
41
+ return { accessToken: String(json.access_token), base }
42
+ }
43
+
44
+ function resolveIdempotencyKey(req: MedusaRequest, suffix: string, fallback: string) {
45
+ const header =
46
+ req.headers["idempotency-key"] ||
47
+ req.headers["Idempotency-Key"] ||
48
+ req.headers["x-idempotency-key"] ||
49
+ req.headers["X-Idempotency-Key"]
50
+ const key = Array.isArray(header) ? header[0] : header
51
+ if (key && String(key).trim()) {
52
+ return `${String(key).trim()}-${suffix}`
53
+ }
54
+ return fallback || `pp-${suffix}-${randomUUID()}`
55
+ }
56
+
57
+ /**
58
+ * Find the PayPal payment session for a cart using direct DB query.
59
+ * Replaces the broken paymentCollectionService.retrieveByCartId() call.
60
+ */
61
+ async function findPayPalSessionForCart(cartId: string): Promise<{
62
+ session_id: string
63
+ session_data: Record<string, any>
64
+ session_status: string
65
+ } | null> {
66
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
67
+ try {
68
+ const { rows } = await pool.query(
69
+ `SELECT ps.id as session_id, ps.data as session_data, ps.status as session_status
70
+ FROM payment_session ps
71
+ JOIN payment_collection pc ON ps.payment_collection_id = pc.id
72
+ JOIN cart_payment_collection cpc ON cpc.payment_collection_id = pc.id
73
+ WHERE cpc.cart_id = $1
74
+ AND ps.provider_id LIKE '%paypal%'
75
+ ORDER BY ps.created_at DESC
76
+ LIMIT 1`,
77
+ [cartId]
78
+ )
79
+ return rows[0] ?? null
80
+ } catch {
81
+ return null
82
+ } finally {
83
+ await pool.end()
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Update the PayPal payment session status and data directly via DB.
89
+ */
90
+ async function updatePayPalSession(
91
+ sessionId: string,
92
+ status: string,
93
+ extraData: Record<string, any>
94
+ ): Promise<void> {
95
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
96
+ try {
97
+ await pool.query(
98
+ `UPDATE payment_session
99
+ SET status = $1,
100
+ data = data || $2::jsonb
101
+ WHERE id = $3`,
102
+ [status, JSON.stringify(extraData), sessionId]
103
+ )
104
+ } catch {
105
+ // ignore
106
+ } finally {
107
+ await pool.end()
108
+ }
109
+ }
110
+
111
+ async function attachPayPalCaptureToSession(
112
+ cartId: string,
113
+ orderId: string,
114
+ capture: any
115
+ ) {
116
+ try {
117
+ const session = await findPayPalSessionForCart(cartId)
118
+ if (!session) {
119
+ console.warn("[PayPal] attachPayPalCaptureToSession: no session found for cart", cartId)
120
+ return
121
+ }
122
+
123
+ const captureId =
124
+ capture?.id ||
125
+ capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
126
+
127
+ await updatePayPalSession(session.session_id, "authorized", {
128
+ paypal: {
129
+ ...((session.session_data || {}).paypal || {}),
130
+ order_id: orderId,
131
+ capture_id: captureId,
132
+ capture,
133
+ },
134
+ })
135
+
136
+ console.info("[PayPal] session authorized via DB:", session.session_id)
137
+ } catch {
138
+ // ignore
139
+ }
140
+ }
141
+
142
+ async function attachPayPalAuthorizationToSession(
143
+ cartId: string,
144
+ orderId: string,
145
+ authorization: any
146
+ ) {
147
+ try {
148
+ const session = await findPayPalSessionForCart(cartId)
149
+ if (!session) {
150
+ console.warn("[PayPal] attachPayPalAuthorizationToSession: no session found for cart", cartId)
151
+ return
152
+ }
153
+
154
+ const authorizationId =
155
+ authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
156
+
157
+ await updatePayPalSession(session.session_id, "authorized", {
158
+ paypal: {
159
+ ...((session.session_data || {}).paypal || {}),
160
+ order_id: orderId,
161
+ authorization_id: authorizationId,
162
+ authorization,
163
+ },
164
+ })
165
+
166
+ console.info("[PayPal] session authorized via DB:", session.session_id)
167
+ } catch {
168
+ // ignore
169
+ }
170
+ }
171
+
172
+ async function getExistingCapture(cartId: string, orderId: string) {
173
+ try {
174
+ const session = await findPayPalSessionForCart(cartId)
175
+ if (!session) return null
176
+
177
+ const paypalData = (session.session_data || {}).paypal || {}
178
+ const existingOrderId = String(paypalData.order_id || "")
179
+ if (existingOrderId && existingOrderId !== orderId) return null
180
+ if (paypalData.capture) return paypalData.capture
181
+ if (paypalData.capture_id) return { id: paypalData.capture_id }
182
+ return null
183
+ } catch {
184
+ return null
185
+ }
186
+ }
187
+
188
+ export async function POST(req: MedusaRequest, res: MedusaResponse) {
189
+ const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
190
+ let debugId: string | null = null
191
+
192
+ try {
193
+ const body = (req.body || {}) as Body
194
+ const cartId = body.cart_id
195
+ const orderId = body.order_id
196
+
197
+ if (!cartId || !orderId) {
198
+ return res.status(400).json({ message: "cart_id and order_id are required" })
199
+ }
200
+
201
+ const existingCapture = await getExistingCapture(cartId, orderId)
202
+ if (existingCapture) {
203
+ return res.json({ capture: existingCapture })
204
+ }
205
+
206
+ const creds = await paypal.getActiveCredentials()
207
+ const { accessToken, base } = await getPayPalAccessToken(creds)
208
+ const settings = await paypal.getSettings().catch(() => ({}))
209
+ const data =
210
+ settings && typeof settings === "object" && "data" in settings
211
+ ? ((settings as { data?: Record<string, any> }).data ?? {})
212
+ : {}
213
+ const additionalSettings = (data.additional_settings || {}) as Record<string, any>
214
+ const paymentAction =
215
+ typeof additionalSettings.paymentAction === "string"
216
+ ? additionalSettings.paymentAction
217
+ : "capture"
218
+
219
+ const requestId = resolveIdempotencyKey(req, "capture-order", `pp-capture-${orderId}`)
220
+ const endpoint =
221
+ paymentAction === "authorize"
222
+ ? `${base}/v2/checkout/orders/${orderId}/authorize`
223
+ : `${base}/v2/checkout/orders/${orderId}/capture`
224
+
225
+ const ppResp = await fetch(endpoint, {
226
+ method: "POST",
227
+ headers: {
228
+ Authorization: `Bearer ${accessToken}`,
229
+ "Content-Type": "application/json",
230
+ "PayPal-Request-Id": requestId,
231
+ },
232
+ })
233
+
234
+ const ppText = await ppResp.text()
235
+ debugId = ppResp.headers.get("paypal-debug-id")
236
+ if (!ppResp.ok) {
237
+ throw new Error(
238
+ `PayPal capture error (${ppResp.status}): ${ppText}${debugId ? ` debug_id=${debugId}` : ""}`
239
+ )
240
+ }
241
+
242
+ const payload = JSON.parse(ppText)
243
+
244
+ if (paymentAction === "authorize") {
245
+ await attachPayPalAuthorizationToSession(cartId, orderId, payload)
246
+ } else {
247
+ await attachPayPalCaptureToSession(cartId, orderId, payload)
248
+ }
249
+
250
+ console.info("[PayPal] capture-order", {
251
+ cart_id: cartId,
252
+ order_id: orderId,
253
+ request_id: requestId,
254
+ debug_id: ppResp.headers.get("paypal-debug-id"),
255
+ capture_id: payload?.id,
256
+ })
257
+
258
+ try {
259
+ await paypal.recordMetric(
260
+ paymentAction === "authorize" ? "authorize_order_success" : "capture_order_success"
261
+ )
262
+ } catch {
263
+ // ignore metrics failures
264
+ }
265
+
266
+ return paymentAction === "authorize"
267
+ ? res.json({ authorization: payload })
268
+ : res.json({ capture: payload })
269
+ } catch (e: any) {
270
+ try {
271
+ const body = (req.body || {}) as Body
272
+ await paypal.recordAuditEvent("capture_order_failed", {
273
+ cart_id: body.cart_id,
274
+ order_id: body.order_id,
275
+ debug_id: debugId,
276
+ message: e?.message || String(e),
277
+ })
278
+ await paypal.recordMetric("capture_order_failed")
279
+ } catch {
280
+ // ignore audit logging failures
281
+ }
282
+ return res.status(500).json({ message: e?.message || "Failed to capture PayPal order" })
283
+ }
284
+ }
@@ -0,0 +1,65 @@
1
+ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework"
2
+ import { Pool } from "pg"
3
+
4
+ /**
5
+ * POST /store/paypal-complete
6
+ * Body: { cart_id, order_id, capture_id }
7
+ *
8
+ * Directly sets the PayPal payment session to "authorized" in the DB.
9
+ * The frontend then calls placeOrder() which handles cart completion + redirect.
10
+ */
11
+ export async function POST(req: MedusaRequest, res: MedusaResponse) {
12
+ const { cart_id, order_id, capture_id } = req.body as {
13
+ cart_id?: string
14
+ order_id?: string
15
+ capture_id?: string
16
+ }
17
+
18
+ if (!cart_id) {
19
+ return res.status(400).json({ error: "cart_id is required" })
20
+ }
21
+
22
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
23
+
24
+ try {
25
+ const { rows } = await pool.query(
26
+ `UPDATE payment_session
27
+ SET status = 'authorized',
28
+ data = data || $1::jsonb
29
+ WHERE id = (
30
+ SELECT ps.id
31
+ FROM payment_session ps
32
+ JOIN payment_collection pc ON ps.payment_collection_id = pc.id
33
+ JOIN cart_payment_collection cpc ON cpc.payment_collection_id = pc.id
34
+ WHERE cpc.cart_id = $2
35
+ AND ps.provider_id LIKE '%paypal%'
36
+ ORDER BY ps.created_at DESC
37
+ LIMIT 1
38
+ )
39
+ RETURNING id, status`,
40
+ [
41
+ JSON.stringify({
42
+ paypal: {
43
+ order_id: order_id ?? null,
44
+ capture_id: capture_id ?? null,
45
+ },
46
+ authorized_at: new Date().toISOString(),
47
+ }),
48
+ cart_id,
49
+ ]
50
+ )
51
+
52
+ console.log("[paypal-complete] session authorized:", rows)
53
+
54
+ if (!rows.length) {
55
+ return res.status(400).json({ error: "No PayPal payment session found for cart" })
56
+ }
57
+
58
+ return res.json({ success: true, session_id: rows[0].id })
59
+ } catch (e: any) {
60
+ console.error("[paypal-complete] error:", e?.message || e)
61
+ return res.status(500).json({ error: e?.message || "Internal error" })
62
+ } finally {
63
+ await pool.end()
64
+ }
65
+ }