better-auth-mercadopago 0.1.9 → 0.2.1
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/dist/client.d.ts +86 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +271 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/security.d.ts +103 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +300 -0
- package/dist/security.js.map +1 -0
- package/dist/server.d.ts +638 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1095 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +1 -1
package/dist/server.js
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import { generateId } from "better-auth";
|
|
2
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
3
|
+
import { Customer, MercadoPagoConfig, Payment, PreApproval, PreApprovalPlan, Preference, } from "mercadopago";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { handleMercadoPagoError, idempotencyStore, isValidWebhookTopic, rateLimiter, sanitizeMetadata, ValidationRules, validateCallbackUrl, validateIdempotencyKey, validatePaymentAmount, verifyWebhookSignature, } from "./security";
|
|
6
|
+
export const mercadoPagoPlugin = (options) => {
|
|
7
|
+
const client = new MercadoPagoConfig({
|
|
8
|
+
accessToken: options.accessToken,
|
|
9
|
+
});
|
|
10
|
+
const preferenceClient = new Preference(client);
|
|
11
|
+
const paymentClient = new Payment(client);
|
|
12
|
+
const customerClient = new Customer(client);
|
|
13
|
+
const preApprovalClient = new PreApproval(client);
|
|
14
|
+
const preApprovalPlanClient = new PreApprovalPlan(client);
|
|
15
|
+
return {
|
|
16
|
+
id: "mercadopago",
|
|
17
|
+
schema: {
|
|
18
|
+
// Customer table - stores MP customer info
|
|
19
|
+
mercadoPagoCustomer: {
|
|
20
|
+
fields: {
|
|
21
|
+
id: { type: "string", required: true },
|
|
22
|
+
userId: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: true,
|
|
25
|
+
references: { model: "user", field: "id", onDelete: "cascade" },
|
|
26
|
+
},
|
|
27
|
+
mercadoPagoId: { type: "string", required: true, unique: true },
|
|
28
|
+
email: { type: "string", required: true },
|
|
29
|
+
createdAt: { type: "date", required: true },
|
|
30
|
+
updatedAt: { type: "date", required: true },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
// Payment table - one-time payments
|
|
34
|
+
mercadoPagoPayment: {
|
|
35
|
+
fields: {
|
|
36
|
+
id: { type: "string", required: true },
|
|
37
|
+
userId: {
|
|
38
|
+
type: "string",
|
|
39
|
+
required: true,
|
|
40
|
+
references: { model: "user", field: "id", onDelete: "cascade" },
|
|
41
|
+
},
|
|
42
|
+
mercadoPagoPaymentId: {
|
|
43
|
+
type: "string",
|
|
44
|
+
required: true,
|
|
45
|
+
unique: true,
|
|
46
|
+
},
|
|
47
|
+
preferenceId: { type: "string", required: true },
|
|
48
|
+
status: { type: "string", required: true }, // pending, approved, authorized, rejected, cancelled, refunded, charged_back
|
|
49
|
+
statusDetail: { type: "string" }, // accredited, pending_contingency, pending_review_manual, cc_rejected_*, etc
|
|
50
|
+
amount: { type: "number", required: true },
|
|
51
|
+
currency: { type: "string", required: true },
|
|
52
|
+
paymentMethodId: { type: "string" }, // visa, master, pix, etc
|
|
53
|
+
paymentTypeId: { type: "string" }, // credit_card, debit_card, ticket, etc
|
|
54
|
+
metadata: { type: "string" }, // JSON stringified
|
|
55
|
+
createdAt: { type: "date", required: true },
|
|
56
|
+
updatedAt: { type: "date", required: true },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
// Subscription table
|
|
60
|
+
mercadoPagoSubscription: {
|
|
61
|
+
fields: {
|
|
62
|
+
id: { type: "string", required: true },
|
|
63
|
+
userId: {
|
|
64
|
+
type: "string",
|
|
65
|
+
required: true,
|
|
66
|
+
references: { model: "user", field: "id", onDelete: "cascade" },
|
|
67
|
+
},
|
|
68
|
+
mercadoPagoSubscriptionId: {
|
|
69
|
+
type: "string",
|
|
70
|
+
required: true,
|
|
71
|
+
unique: true,
|
|
72
|
+
},
|
|
73
|
+
planId: { type: "string", required: true },
|
|
74
|
+
status: { type: "string", required: true }, // authorized, paused, cancelled, pending
|
|
75
|
+
reason: { type: "string" }, // Reason for status (e.g., payment_failed, user_cancelled)
|
|
76
|
+
nextPaymentDate: { type: "date" },
|
|
77
|
+
lastPaymentDate: { type: "date" },
|
|
78
|
+
summarized: { type: "string" }, // JSON with charges, charged_amount, pending_charge_amount
|
|
79
|
+
metadata: { type: "string" }, // JSON stringified
|
|
80
|
+
createdAt: { type: "date", required: true },
|
|
81
|
+
updatedAt: { type: "date", required: true },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
// Preapproval Plan table (reusable subscription plans)
|
|
85
|
+
mercadoPagoPreapprovalPlan: {
|
|
86
|
+
fields: {
|
|
87
|
+
id: { type: "string", required: true },
|
|
88
|
+
mercadoPagoPlanId: { type: "string", required: true, unique: true },
|
|
89
|
+
reason: { type: "string", required: true }, // Plan description
|
|
90
|
+
frequency: { type: "number", required: true },
|
|
91
|
+
frequencyType: { type: "string", required: true }, // days, months
|
|
92
|
+
transactionAmount: { type: "number", required: true },
|
|
93
|
+
currencyId: { type: "string", required: true },
|
|
94
|
+
repetitions: { type: "number" }, // null = infinite
|
|
95
|
+
freeTrial: { type: "string" }, // JSON with frequency and frequency_type
|
|
96
|
+
metadata: { type: "string" }, // JSON stringified
|
|
97
|
+
createdAt: { type: "date", required: true },
|
|
98
|
+
updatedAt: { type: "date", required: true },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
// Split payments table (for marketplace)
|
|
102
|
+
mercadoPagoMarketplaceSplit: {
|
|
103
|
+
fields: {
|
|
104
|
+
id: { type: "string", required: true },
|
|
105
|
+
paymentId: {
|
|
106
|
+
type: "string",
|
|
107
|
+
required: true,
|
|
108
|
+
references: {
|
|
109
|
+
model: "mercadoPagoPayment",
|
|
110
|
+
field: "id",
|
|
111
|
+
onDelete: "cascade",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
// Changed naming to be more clear
|
|
115
|
+
collectorId: { type: "string", required: true }, // MP User ID who receives the money (seller)
|
|
116
|
+
collectorEmail: { type: "string", required: true }, // Email of who receives money
|
|
117
|
+
applicationFeeAmount: { type: "number" }, // Platform commission in absolute value
|
|
118
|
+
applicationFeePercentage: { type: "number" }, // Platform commission percentage
|
|
119
|
+
netAmount: { type: "number", required: true }, // Amount that goes to collector (seller)
|
|
120
|
+
metadata: { type: "string" },
|
|
121
|
+
createdAt: { type: "date", required: true },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
// OAuth tokens for marketplace (to make payments on behalf of sellers)
|
|
125
|
+
mercadoPagoOAuthToken: {
|
|
126
|
+
fields: {
|
|
127
|
+
id: { type: "string", required: true },
|
|
128
|
+
userId: {
|
|
129
|
+
type: "string",
|
|
130
|
+
required: true,
|
|
131
|
+
references: { model: "user", field: "id", onDelete: "cascade" },
|
|
132
|
+
},
|
|
133
|
+
accessToken: { type: "string", required: true },
|
|
134
|
+
refreshToken: { type: "string", required: true },
|
|
135
|
+
publicKey: { type: "string", required: true },
|
|
136
|
+
mercadoPagoUserId: { type: "string", required: true, unique: true },
|
|
137
|
+
expiresAt: { type: "date", required: true },
|
|
138
|
+
createdAt: { type: "date", required: true },
|
|
139
|
+
updatedAt: { type: "date", required: true },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
endpoints: {
|
|
144
|
+
// Get or create customer automatically
|
|
145
|
+
getOrCreateCustomer: createAuthEndpoint("/mercado-pago/customer", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
requireAuth: true,
|
|
148
|
+
body: z.object({
|
|
149
|
+
email: z.string().email().optional(),
|
|
150
|
+
firstName: z.string().optional(),
|
|
151
|
+
lastName: z.string().optional(),
|
|
152
|
+
}),
|
|
153
|
+
}, async (ctx) => {
|
|
154
|
+
const session = ctx.context.session;
|
|
155
|
+
if (!session) {
|
|
156
|
+
throw new APIError("UNAUTHORIZED", {
|
|
157
|
+
message: "You must be logged in",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const { email, firstName, lastName } = ctx.body;
|
|
161
|
+
const userEmail = email || session.user.email;
|
|
162
|
+
// Check if customer already exists
|
|
163
|
+
const existingCustomer = await ctx.context.adapter.findOne({
|
|
164
|
+
model: "mercadoPagoCustomer",
|
|
165
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
166
|
+
});
|
|
167
|
+
if (existingCustomer) {
|
|
168
|
+
return ctx.json({ customer: existingCustomer });
|
|
169
|
+
}
|
|
170
|
+
// Create customer in Mercado Pago
|
|
171
|
+
const mpCustomer = await customerClient.create({
|
|
172
|
+
body: {
|
|
173
|
+
email: userEmail,
|
|
174
|
+
first_name: firstName,
|
|
175
|
+
last_name: lastName,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
// Save to database
|
|
179
|
+
const customer = await ctx.context.adapter.create({
|
|
180
|
+
model: "mercadoPagoCustomer",
|
|
181
|
+
data: {
|
|
182
|
+
id: generateId(),
|
|
183
|
+
userId: session.user.id,
|
|
184
|
+
mercadoPagoId: mpCustomer.id,
|
|
185
|
+
email: userEmail,
|
|
186
|
+
createdAt: new Date(),
|
|
187
|
+
updatedAt: new Date(),
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return ctx.json({ customer });
|
|
191
|
+
}),
|
|
192
|
+
// OAuth: Get authorization URL for marketplace sellers
|
|
193
|
+
getOAuthUrl: createAuthEndpoint("/mercado-pago/oauth/authorize", {
|
|
194
|
+
method: "GET",
|
|
195
|
+
requireAuth: true,
|
|
196
|
+
query: z.object({
|
|
197
|
+
redirectUri: z.string().url(),
|
|
198
|
+
}),
|
|
199
|
+
}, async (ctx) => {
|
|
200
|
+
const session = ctx.context.session;
|
|
201
|
+
if (!session) {
|
|
202
|
+
throw new APIError("UNAUTHORIZED");
|
|
203
|
+
}
|
|
204
|
+
if (!options.appId) {
|
|
205
|
+
throw new APIError("BAD_REQUEST", {
|
|
206
|
+
message: "OAuth not configured. Please provide appId in plugin options",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const { redirectUri } = ctx.query;
|
|
210
|
+
// Validate redirect URI is trusted
|
|
211
|
+
if (!ctx.context.isTrustedOrigin(redirectUri)) {
|
|
212
|
+
throw new APIError("FORBIDDEN", {
|
|
213
|
+
message: "Redirect URI not in trusted origins",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const authUrl = `https://auth.mercadopago.com/authorization?client_id=${options.appId}&response_type=code&platform_id=mp&state=${session.user.id}&redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
217
|
+
return ctx.json({ authUrl });
|
|
218
|
+
}),
|
|
219
|
+
// OAuth: Exchange code for access token
|
|
220
|
+
exchangeOAuthCode: createAuthEndpoint("/mercado-pago/oauth/callback", {
|
|
221
|
+
method: "POST",
|
|
222
|
+
requireAuth: true,
|
|
223
|
+
body: z.object({
|
|
224
|
+
code: z.string(),
|
|
225
|
+
redirectUri: z.string().url(),
|
|
226
|
+
}),
|
|
227
|
+
}, async (ctx) => {
|
|
228
|
+
const session = ctx.context.session;
|
|
229
|
+
if (!session) {
|
|
230
|
+
throw new APIError("UNAUTHORIZED");
|
|
231
|
+
}
|
|
232
|
+
if (!options.appId || !options.appSecret) {
|
|
233
|
+
throw new APIError("BAD_REQUEST", {
|
|
234
|
+
message: "OAuth not configured",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const { code, redirectUri } = ctx.body;
|
|
238
|
+
// Exchange code for token
|
|
239
|
+
const tokenResponse = await fetch("https://api.mercadopago.com/oauth/token", {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "Content-Type": "application/json" },
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
client_id: options.appId,
|
|
244
|
+
client_secret: options.appSecret,
|
|
245
|
+
grant_type: "authorization_code",
|
|
246
|
+
code,
|
|
247
|
+
redirect_uri: redirectUri,
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
if (!tokenResponse.ok) {
|
|
251
|
+
throw new APIError("BAD_REQUEST", {
|
|
252
|
+
message: "Failed to exchange OAuth code",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const tokenData = (await tokenResponse.json());
|
|
256
|
+
// Save OAuth token
|
|
257
|
+
const oauthToken = await ctx.context.adapter.create({
|
|
258
|
+
model: "mercadoPagoOAuthToken",
|
|
259
|
+
data: {
|
|
260
|
+
id: generateId(),
|
|
261
|
+
userId: session.user.id,
|
|
262
|
+
accessToken: tokenData.access_token,
|
|
263
|
+
refreshToken: tokenData.refresh_token,
|
|
264
|
+
publicKey: tokenData.public_key,
|
|
265
|
+
mercadoPagoUserId: tokenData.user_id.toString(),
|
|
266
|
+
expiresAt: new Date(Date.now() + tokenData.expires_in * 1000),
|
|
267
|
+
createdAt: new Date(),
|
|
268
|
+
updatedAt: new Date(),
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
return ctx.json({
|
|
272
|
+
success: true,
|
|
273
|
+
oauthToken: {
|
|
274
|
+
id: oauthToken.id,
|
|
275
|
+
mercadoPagoUserId: oauthToken.mercadoPagoUserId,
|
|
276
|
+
expiresAt: oauthToken.expiresAt,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}),
|
|
280
|
+
// Create a reusable preapproval plan (subscription plan)
|
|
281
|
+
createPreapprovalPlan: createAuthEndpoint("/mercado-pago/plan/create", {
|
|
282
|
+
method: "POST",
|
|
283
|
+
body: z.object({
|
|
284
|
+
reason: z.string(), // Plan description (e.g., "Premium Monthly")
|
|
285
|
+
autoRecurring: z.object({
|
|
286
|
+
frequency: z.number(), // 1, 7, 30, etc
|
|
287
|
+
frequencyType: z.enum(["days", "months"]),
|
|
288
|
+
transactionAmount: z.number(),
|
|
289
|
+
currencyId: z.string().default("ARS"),
|
|
290
|
+
freeTrial: z
|
|
291
|
+
.object({
|
|
292
|
+
frequency: z.number(),
|
|
293
|
+
frequencyType: z.enum(["days", "months"]),
|
|
294
|
+
})
|
|
295
|
+
.optional(),
|
|
296
|
+
}),
|
|
297
|
+
repetitions: z.number().optional(), // null = infinite
|
|
298
|
+
backUrl: z.string().optional(),
|
|
299
|
+
metadata: z.record(z.any()).optional(),
|
|
300
|
+
}),
|
|
301
|
+
}, async (ctx) => {
|
|
302
|
+
const { reason, autoRecurring, repetitions, backUrl, metadata } = ctx.body;
|
|
303
|
+
const baseUrl = options.baseUrl || ctx.context.baseURL;
|
|
304
|
+
// Create preapproval plan
|
|
305
|
+
const planBody = {
|
|
306
|
+
reason,
|
|
307
|
+
auto_recurring: {
|
|
308
|
+
frequency: autoRecurring.frequency,
|
|
309
|
+
frequency_type: autoRecurring.frequencyType,
|
|
310
|
+
transaction_amount: autoRecurring.transactionAmount,
|
|
311
|
+
currency_id: autoRecurring.currencyId,
|
|
312
|
+
},
|
|
313
|
+
back_url: backUrl || `${baseUrl}/plan/created`,
|
|
314
|
+
};
|
|
315
|
+
if (repetitions && planBody.auto_recurring) {
|
|
316
|
+
planBody.auto_recurring.repetitions = repetitions;
|
|
317
|
+
}
|
|
318
|
+
if (autoRecurring.freeTrial && planBody.auto_recurring) {
|
|
319
|
+
planBody.auto_recurring.free_trial = {
|
|
320
|
+
frequency: autoRecurring.freeTrial.frequency,
|
|
321
|
+
frequency_type: autoRecurring.freeTrial.frequencyType,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const mpPlan = await preApprovalPlanClient.create({ body: planBody });
|
|
325
|
+
// Save plan to database
|
|
326
|
+
const plan = await ctx.context.adapter.create({
|
|
327
|
+
model: "mercadoPagoPreapprovalPlan",
|
|
328
|
+
data: {
|
|
329
|
+
id: generateId(),
|
|
330
|
+
mercadoPagoPlanId: mpPlan.id,
|
|
331
|
+
reason,
|
|
332
|
+
frequency: autoRecurring.frequency,
|
|
333
|
+
frequencyType: autoRecurring.frequencyType,
|
|
334
|
+
transactionAmount: autoRecurring.transactionAmount,
|
|
335
|
+
currencyId: autoRecurring.currencyId,
|
|
336
|
+
repetitions: repetitions || null,
|
|
337
|
+
freeTrial: autoRecurring.freeTrial
|
|
338
|
+
? JSON.stringify(autoRecurring.freeTrial)
|
|
339
|
+
: null,
|
|
340
|
+
metadata: JSON.stringify(metadata || {}),
|
|
341
|
+
createdAt: new Date(),
|
|
342
|
+
updatedAt: new Date(),
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
return ctx.json({ plan });
|
|
346
|
+
}),
|
|
347
|
+
// List all preapproval plans
|
|
348
|
+
listPreapprovalPlans: createAuthEndpoint("/mercado-pago/plans", {
|
|
349
|
+
method: "GET",
|
|
350
|
+
}, async (ctx) => {
|
|
351
|
+
const plans = await ctx.context.adapter.findMany({
|
|
352
|
+
model: "mercadoPagoPreapprovalPlan",
|
|
353
|
+
});
|
|
354
|
+
return ctx.json({ plans });
|
|
355
|
+
}),
|
|
356
|
+
// Create payment preference
|
|
357
|
+
createPayment: createAuthEndpoint("/mercado-pago/payment/create", {
|
|
358
|
+
method: "POST",
|
|
359
|
+
requireAuth: true,
|
|
360
|
+
body: z.object({
|
|
361
|
+
items: z
|
|
362
|
+
.array(z.object({
|
|
363
|
+
id: z.string(),
|
|
364
|
+
title: z.string().min(1).max(256),
|
|
365
|
+
quantity: z.number().int().min(1).max(10000),
|
|
366
|
+
unitPrice: z.number().positive().max(999999999),
|
|
367
|
+
currencyId: z.string().default("ARS"),
|
|
368
|
+
}))
|
|
369
|
+
.min(1)
|
|
370
|
+
.max(100),
|
|
371
|
+
metadata: z.record(z.any()).optional(),
|
|
372
|
+
marketplace: z
|
|
373
|
+
.object({
|
|
374
|
+
collectorId: z.string(),
|
|
375
|
+
applicationFee: z.number().positive().optional(),
|
|
376
|
+
applicationFeePercentage: z.number().min(0).max(100).optional(),
|
|
377
|
+
})
|
|
378
|
+
.optional(),
|
|
379
|
+
successUrl: z.string().url().optional(),
|
|
380
|
+
failureUrl: z.string().url().optional(),
|
|
381
|
+
pendingUrl: z.string().url().optional(),
|
|
382
|
+
idempotencyKey: z.string().optional(),
|
|
383
|
+
}),
|
|
384
|
+
}, async (ctx) => {
|
|
385
|
+
const session = ctx.context.session;
|
|
386
|
+
if (!session) {
|
|
387
|
+
throw new APIError("UNAUTHORIZED");
|
|
388
|
+
}
|
|
389
|
+
// Rate limiting: 10 payment creations per minute per user
|
|
390
|
+
const rateLimitKey = `payment:create:${session.user.id}`;
|
|
391
|
+
if (!rateLimiter.check(rateLimitKey, 10, 60 * 1000)) {
|
|
392
|
+
throw new APIError("TOO_MANY_REQUESTS", {
|
|
393
|
+
message: "Too many payment creation attempts. Please try again later.",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const { items, metadata, marketplace, successUrl, failureUrl, pendingUrl, idempotencyKey, } = ctx.body;
|
|
397
|
+
// Idempotency check
|
|
398
|
+
if (idempotencyKey) {
|
|
399
|
+
if (!validateIdempotencyKey(idempotencyKey)) {
|
|
400
|
+
throw new APIError("BAD_REQUEST", {
|
|
401
|
+
message: "Invalid idempotency key format",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const cachedResult = idempotencyStore.get(idempotencyKey);
|
|
405
|
+
if (cachedResult) {
|
|
406
|
+
return ctx.json(cachedResult);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Validate URLs if provided
|
|
410
|
+
if (options.trustedOrigins) {
|
|
411
|
+
const urls = [successUrl, failureUrl, pendingUrl].filter(Boolean);
|
|
412
|
+
for (const url of urls) {
|
|
413
|
+
if (!validateCallbackUrl(url, options.trustedOrigins)) {
|
|
414
|
+
throw new APIError("FORBIDDEN", {
|
|
415
|
+
message: `URL ${url} is not in trusted origins`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Validate currency
|
|
421
|
+
if (items.some((item) => !ValidationRules.currency(item.currencyId))) {
|
|
422
|
+
throw new APIError("BAD_REQUEST", {
|
|
423
|
+
message: "Invalid currency code",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
// Sanitize metadata
|
|
427
|
+
const sanitizedMetadata = metadata ? sanitizeMetadata(metadata) : {};
|
|
428
|
+
// Ensure customer exists
|
|
429
|
+
let customer = await ctx.context.adapter.findOne({
|
|
430
|
+
model: "mercadoPagoCustomer",
|
|
431
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
432
|
+
});
|
|
433
|
+
if (!customer) {
|
|
434
|
+
try {
|
|
435
|
+
const mpCustomer = await customerClient.create({
|
|
436
|
+
body: { email: session.user.email },
|
|
437
|
+
});
|
|
438
|
+
customer = await ctx.context.adapter.create({
|
|
439
|
+
model: "mercadoPagoCustomer",
|
|
440
|
+
data: {
|
|
441
|
+
id: generateId(),
|
|
442
|
+
userId: session.user.id,
|
|
443
|
+
mercadoPagoId: mpCustomer.id,
|
|
444
|
+
email: session.user.email,
|
|
445
|
+
createdAt: new Date(),
|
|
446
|
+
updatedAt: new Date(),
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
handleMercadoPagoError(error);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const baseUrl = options.baseUrl || ctx.context.baseURL;
|
|
455
|
+
// Calculate total amount
|
|
456
|
+
const totalAmount = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
|
457
|
+
// Validate total amount
|
|
458
|
+
if (!ValidationRules.amount(totalAmount)) {
|
|
459
|
+
throw new APIError("BAD_REQUEST", {
|
|
460
|
+
message: "Invalid payment amount",
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
// Calculate marketplace fees
|
|
464
|
+
let applicationFeeAmount = 0;
|
|
465
|
+
if (marketplace) {
|
|
466
|
+
if (marketplace.applicationFee) {
|
|
467
|
+
applicationFeeAmount = marketplace.applicationFee;
|
|
468
|
+
}
|
|
469
|
+
else if (marketplace.applicationFeePercentage) {
|
|
470
|
+
applicationFeeAmount =
|
|
471
|
+
(totalAmount * marketplace.applicationFeePercentage) / 100;
|
|
472
|
+
}
|
|
473
|
+
// Validate fee doesn't exceed total
|
|
474
|
+
if (applicationFeeAmount >= totalAmount) {
|
|
475
|
+
throw new APIError("BAD_REQUEST", {
|
|
476
|
+
message: "Application fee cannot exceed total amount",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Create preference with marketplace config
|
|
481
|
+
const preferenceBody = {
|
|
482
|
+
items: items.map((item) => ({
|
|
483
|
+
id: item.id,
|
|
484
|
+
title: item.title,
|
|
485
|
+
quantity: item.quantity,
|
|
486
|
+
unit_price: item.unitPrice,
|
|
487
|
+
currency_id: item.currencyId,
|
|
488
|
+
})),
|
|
489
|
+
payer: {
|
|
490
|
+
email: session.user.email,
|
|
491
|
+
},
|
|
492
|
+
back_urls: {
|
|
493
|
+
success: successUrl || `${baseUrl}/payment/success`,
|
|
494
|
+
failure: failureUrl || `${baseUrl}/payment/failure`,
|
|
495
|
+
pending: pendingUrl || `${baseUrl}/payment/pending`,
|
|
496
|
+
},
|
|
497
|
+
notification_url: `${baseUrl}/api/auth/mercado-pago/webhook`,
|
|
498
|
+
metadata: {
|
|
499
|
+
...sanitizedMetadata,
|
|
500
|
+
userId: session.user.id,
|
|
501
|
+
customerId: customer?.id,
|
|
502
|
+
},
|
|
503
|
+
expires: true,
|
|
504
|
+
expiration_date_from: new Date().toISOString(),
|
|
505
|
+
expiration_date_to: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days
|
|
506
|
+
};
|
|
507
|
+
// Add marketplace config if provided
|
|
508
|
+
if (marketplace) {
|
|
509
|
+
preferenceBody.marketplace = marketplace.collectorId;
|
|
510
|
+
preferenceBody.marketplace_fee = applicationFeeAmount;
|
|
511
|
+
}
|
|
512
|
+
let preference;
|
|
513
|
+
try {
|
|
514
|
+
preference = await preferenceClient.create({
|
|
515
|
+
body: preferenceBody,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
handleMercadoPagoError(error);
|
|
520
|
+
}
|
|
521
|
+
// Save payment to database
|
|
522
|
+
const payment = await ctx.context.adapter.create({
|
|
523
|
+
model: "mercadoPagoPayment",
|
|
524
|
+
data: {
|
|
525
|
+
id: generateId(),
|
|
526
|
+
userId: session.user.id,
|
|
527
|
+
mercadoPagoPaymentId: preference.id,
|
|
528
|
+
preferenceId: preference.id,
|
|
529
|
+
status: "pending",
|
|
530
|
+
amount: totalAmount,
|
|
531
|
+
currency: items[0]?.currencyId || "ARS",
|
|
532
|
+
metadata: JSON.stringify(sanitizedMetadata),
|
|
533
|
+
createdAt: new Date(),
|
|
534
|
+
updatedAt: new Date(),
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
// Save marketplace split info if provided
|
|
538
|
+
if (marketplace) {
|
|
539
|
+
await ctx.context.adapter.create({
|
|
540
|
+
model: "mercadoPagoMarketplaceSplit",
|
|
541
|
+
data: {
|
|
542
|
+
id: generateId(),
|
|
543
|
+
paymentId: payment.id,
|
|
544
|
+
collectorId: marketplace.collectorId,
|
|
545
|
+
collectorEmail: "", // Will be updated via webhook
|
|
546
|
+
applicationFeeAmount,
|
|
547
|
+
applicationFeePercentage: marketplace.applicationFeePercentage,
|
|
548
|
+
netAmount: totalAmount - applicationFeeAmount,
|
|
549
|
+
metadata: JSON.stringify({}),
|
|
550
|
+
createdAt: new Date(),
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const result = {
|
|
555
|
+
checkoutUrl: preference.init_point,
|
|
556
|
+
preferenceId: preference.id,
|
|
557
|
+
payment,
|
|
558
|
+
};
|
|
559
|
+
// Store in idempotency cache
|
|
560
|
+
if (idempotencyKey) {
|
|
561
|
+
idempotencyStore.set(idempotencyKey, result);
|
|
562
|
+
}
|
|
563
|
+
return ctx.json(result);
|
|
564
|
+
}),
|
|
565
|
+
// Create subscription (supports both with and without preapproval plan)
|
|
566
|
+
createSubscription: createAuthEndpoint("/mercado-pago/subscription/create", {
|
|
567
|
+
method: "POST",
|
|
568
|
+
requireAuth: true,
|
|
569
|
+
body: z.object({
|
|
570
|
+
// Option 1: Use existing preapproval plan
|
|
571
|
+
preapprovalPlanId: z.string().optional(),
|
|
572
|
+
// Option 2: Create subscription directly without plan
|
|
573
|
+
reason: z.string().optional(), // Description of subscription
|
|
574
|
+
autoRecurring: z
|
|
575
|
+
.object({
|
|
576
|
+
frequency: z.number(), // 1 for monthly
|
|
577
|
+
frequencyType: z.enum(["days", "months"]),
|
|
578
|
+
transactionAmount: z.number(),
|
|
579
|
+
currencyId: z.string().default("ARS"),
|
|
580
|
+
startDate: z.string().optional(), // ISO date
|
|
581
|
+
endDate: z.string().optional(), // ISO date
|
|
582
|
+
freeTrial: z
|
|
583
|
+
.object({
|
|
584
|
+
frequency: z.number(),
|
|
585
|
+
frequencyType: z.enum(["days", "months"]),
|
|
586
|
+
})
|
|
587
|
+
.optional(),
|
|
588
|
+
})
|
|
589
|
+
.optional(),
|
|
590
|
+
backUrl: z.string().optional(),
|
|
591
|
+
metadata: z.record(z.any()).optional(),
|
|
592
|
+
}),
|
|
593
|
+
}, async (ctx) => {
|
|
594
|
+
const session = ctx.context.session;
|
|
595
|
+
if (!session) {
|
|
596
|
+
throw new APIError("UNAUTHORIZED");
|
|
597
|
+
}
|
|
598
|
+
const { preapprovalPlanId, reason, autoRecurring, backUrl, metadata, } = ctx.body;
|
|
599
|
+
// Validate: must provide either preapprovalPlanId OR (reason + autoRecurring)
|
|
600
|
+
if (!preapprovalPlanId) {
|
|
601
|
+
if (!reason || !autoRecurring) {
|
|
602
|
+
throw new APIError("BAD_REQUEST", {
|
|
603
|
+
message: "Must provide either preapprovalPlanId or (reason + autoRecurring)",
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Ensure customer exists
|
|
608
|
+
let customer = await ctx.context.adapter.findOne({
|
|
609
|
+
model: "mercadoPagoCustomer",
|
|
610
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
611
|
+
});
|
|
612
|
+
if (!customer) {
|
|
613
|
+
const mpCustomer = await customerClient.create({
|
|
614
|
+
body: { email: session.user.email },
|
|
615
|
+
});
|
|
616
|
+
customer = await ctx.context.adapter.create({
|
|
617
|
+
model: "mercadoPagoCustomer",
|
|
618
|
+
data: {
|
|
619
|
+
id: generateId(),
|
|
620
|
+
userId: session.user.id,
|
|
621
|
+
mercadoPagoId: mpCustomer.id,
|
|
622
|
+
email: session.user.email,
|
|
623
|
+
createdAt: new Date(),
|
|
624
|
+
updatedAt: new Date(),
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const baseUrl = options.baseUrl || ctx.context.baseURL;
|
|
629
|
+
const subscriptionId = generateId();
|
|
630
|
+
let preapproval;
|
|
631
|
+
// Option 1: Use existing preapproval plan
|
|
632
|
+
if (preapprovalPlanId) {
|
|
633
|
+
preapproval = await preApprovalClient.create({
|
|
634
|
+
body: {
|
|
635
|
+
preapproval_plan_id: preapprovalPlanId,
|
|
636
|
+
payer_email: session.user.email,
|
|
637
|
+
card_token_id: undefined, // Will be provided in checkout
|
|
638
|
+
back_url: backUrl || `${baseUrl}/subscription/success`,
|
|
639
|
+
status: "pending",
|
|
640
|
+
external_reference: subscriptionId,
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// Option 2: Create subscription directly without plan
|
|
645
|
+
else if (autoRecurring) {
|
|
646
|
+
// We verified autoRecurring is defined in the validation step above
|
|
647
|
+
const ar = autoRecurring;
|
|
648
|
+
const autoRecurringBody = {
|
|
649
|
+
frequency: ar.frequency,
|
|
650
|
+
frequency_type: ar.frequencyType,
|
|
651
|
+
transaction_amount: ar.transactionAmount,
|
|
652
|
+
currency_id: ar.currencyId,
|
|
653
|
+
};
|
|
654
|
+
if (ar.startDate) {
|
|
655
|
+
autoRecurringBody.start_date = ar.startDate;
|
|
656
|
+
}
|
|
657
|
+
if (ar.endDate) {
|
|
658
|
+
autoRecurringBody.end_date = ar.endDate;
|
|
659
|
+
}
|
|
660
|
+
if (ar.freeTrial) {
|
|
661
|
+
// @ts-expect-error SDK type definition is missing free_trial
|
|
662
|
+
autoRecurringBody.free_trial = {
|
|
663
|
+
frequency: ar.freeTrial.frequency,
|
|
664
|
+
frequency_type: ar.freeTrial.frequencyType,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
preapproval = await preApprovalClient.create({
|
|
668
|
+
body: {
|
|
669
|
+
reason: reason,
|
|
670
|
+
auto_recurring: autoRecurringBody,
|
|
671
|
+
payer_email: session.user.email,
|
|
672
|
+
back_url: backUrl || `${baseUrl}/subscription/success`,
|
|
673
|
+
status: "pending",
|
|
674
|
+
external_reference: subscriptionId,
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
// Ensure preapproval was created
|
|
679
|
+
if (!preapproval) {
|
|
680
|
+
throw new APIError("BAD_REQUEST", {
|
|
681
|
+
message: "Failed to create subscription",
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
// Save subscription
|
|
685
|
+
const subscription = await ctx.context.adapter.create({
|
|
686
|
+
model: "mercadoPagoSubscription",
|
|
687
|
+
data: {
|
|
688
|
+
id: subscriptionId,
|
|
689
|
+
userId: session.user.id,
|
|
690
|
+
mercadoPagoSubscriptionId: preapproval.id,
|
|
691
|
+
planId: preapprovalPlanId || reason || "direct",
|
|
692
|
+
status: "pending",
|
|
693
|
+
metadata: JSON.stringify(metadata || {}),
|
|
694
|
+
createdAt: new Date(),
|
|
695
|
+
updatedAt: new Date(),
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
return ctx.json({
|
|
699
|
+
checkoutUrl: preapproval.init_point,
|
|
700
|
+
subscription,
|
|
701
|
+
});
|
|
702
|
+
}),
|
|
703
|
+
// Cancel subscription
|
|
704
|
+
cancelSubscription: createAuthEndpoint("/mercado-pago/subscription/cancel", {
|
|
705
|
+
method: "POST",
|
|
706
|
+
requireAuth: true,
|
|
707
|
+
body: z.object({
|
|
708
|
+
subscriptionId: z.string(),
|
|
709
|
+
}),
|
|
710
|
+
}, async (ctx) => {
|
|
711
|
+
const session = ctx.context.session;
|
|
712
|
+
if (!session) {
|
|
713
|
+
throw new APIError("UNAUTHORIZED");
|
|
714
|
+
}
|
|
715
|
+
const { subscriptionId } = ctx.body;
|
|
716
|
+
const subscription = await ctx.context.adapter.findOne({
|
|
717
|
+
model: "mercadoPagoSubscription",
|
|
718
|
+
where: [
|
|
719
|
+
{ field: "id", value: subscriptionId },
|
|
720
|
+
{ field: "userId", value: session.user.id },
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
if (!subscription) {
|
|
724
|
+
throw new APIError("NOT_FOUND", {
|
|
725
|
+
message: "Subscription not found",
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
// Cancel in Mercado Pago
|
|
729
|
+
await preApprovalClient.update({
|
|
730
|
+
id: subscription.mercadoPagoSubscriptionId,
|
|
731
|
+
body: { status: "cancelled" },
|
|
732
|
+
});
|
|
733
|
+
// Update in database
|
|
734
|
+
await ctx.context.adapter.update({
|
|
735
|
+
model: "mercadoPagoSubscription",
|
|
736
|
+
where: [{ field: "id", value: subscriptionId }],
|
|
737
|
+
update: {
|
|
738
|
+
status: "cancelled",
|
|
739
|
+
updatedAt: new Date(),
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
return ctx.json({ success: true });
|
|
743
|
+
}),
|
|
744
|
+
// Get payment status
|
|
745
|
+
getPayment: createAuthEndpoint("/mercado-pago/payment/:id", {
|
|
746
|
+
method: "GET",
|
|
747
|
+
requireAuth: true,
|
|
748
|
+
}, async (ctx) => {
|
|
749
|
+
const paymentId = ctx.params.id;
|
|
750
|
+
const session = ctx.context.session;
|
|
751
|
+
if (!session) {
|
|
752
|
+
throw new APIError("UNAUTHORIZED");
|
|
753
|
+
}
|
|
754
|
+
const payment = await ctx.context.adapter.findOne({
|
|
755
|
+
model: "mercadoPagoPayment",
|
|
756
|
+
where: [
|
|
757
|
+
{ field: "id", value: paymentId },
|
|
758
|
+
{ field: "userId", value: session.user.id },
|
|
759
|
+
],
|
|
760
|
+
});
|
|
761
|
+
if (!payment) {
|
|
762
|
+
throw new APIError("NOT_FOUND", {
|
|
763
|
+
message: "Payment not found",
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
return ctx.json({ payment });
|
|
767
|
+
}),
|
|
768
|
+
// List user payments
|
|
769
|
+
listPayments: createAuthEndpoint("/mercado-pago/payments", {
|
|
770
|
+
method: "GET",
|
|
771
|
+
requireAuth: true,
|
|
772
|
+
query: z.object({
|
|
773
|
+
limit: z.coerce.number().optional().default(10),
|
|
774
|
+
offset: z.coerce.number().optional().default(0),
|
|
775
|
+
}),
|
|
776
|
+
}, async (ctx) => {
|
|
777
|
+
const session = ctx.context.session;
|
|
778
|
+
const { limit, offset } = ctx.query;
|
|
779
|
+
if (!session) {
|
|
780
|
+
throw new APIError("UNAUTHORIZED");
|
|
781
|
+
}
|
|
782
|
+
const payments = await ctx.context.adapter.findMany({
|
|
783
|
+
model: "mercadoPagoPayment",
|
|
784
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
785
|
+
limit,
|
|
786
|
+
offset,
|
|
787
|
+
});
|
|
788
|
+
return ctx.json({ payments });
|
|
789
|
+
}),
|
|
790
|
+
// List user subscriptions
|
|
791
|
+
listSubscriptions: createAuthEndpoint("/mercado-pago/subscriptions", {
|
|
792
|
+
method: "GET",
|
|
793
|
+
requireAuth: true,
|
|
794
|
+
}, async (ctx) => {
|
|
795
|
+
const session = ctx.context.session;
|
|
796
|
+
if (!session) {
|
|
797
|
+
throw new APIError("UNAUTHORIZED");
|
|
798
|
+
}
|
|
799
|
+
const subscriptions = await ctx.context.adapter.findMany({
|
|
800
|
+
model: "mercadoPagoSubscription",
|
|
801
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
802
|
+
});
|
|
803
|
+
return ctx.json({ subscriptions });
|
|
804
|
+
}),
|
|
805
|
+
// Webhook handler
|
|
806
|
+
webhook: createAuthEndpoint("/mercado-pago/webhook", {
|
|
807
|
+
method: "POST",
|
|
808
|
+
}, async (ctx) => {
|
|
809
|
+
// Rate limiting for webhooks: 1000 requests per minute
|
|
810
|
+
const webhookRateLimitKey = "webhook:global";
|
|
811
|
+
if (!rateLimiter.check(webhookRateLimitKey, 1000, 60 * 1000)) {
|
|
812
|
+
throw new APIError("TOO_MANY_REQUESTS", {
|
|
813
|
+
message: "Webhook rate limit exceeded",
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
let notification;
|
|
817
|
+
try {
|
|
818
|
+
notification = ctx.body;
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
throw new APIError("BAD_REQUEST", {
|
|
822
|
+
message: "Invalid JSON payload",
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
// Validate webhook topic
|
|
826
|
+
if (!notification.type ||
|
|
827
|
+
!isValidWebhookTopic(notification.type) ||
|
|
828
|
+
!notification.data?.id) {
|
|
829
|
+
ctx.context.logger.warn("Invalid webhook topic received", {
|
|
830
|
+
type: notification.type,
|
|
831
|
+
});
|
|
832
|
+
return ctx.json({ received: true }); // Return 200 to avoid retries
|
|
833
|
+
}
|
|
834
|
+
if (!ctx.request) {
|
|
835
|
+
throw new APIError("BAD_REQUEST", {
|
|
836
|
+
message: "Missing request",
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
// Verify webhook signature
|
|
840
|
+
if (options.webhookSecret) {
|
|
841
|
+
const xSignature = ctx.request.headers.get("x-signature");
|
|
842
|
+
const xRequestId = ctx.request.headers.get("x-request-id");
|
|
843
|
+
const dataId = notification.data?.id?.toString();
|
|
844
|
+
if (!dataId) {
|
|
845
|
+
throw new APIError("BAD_REQUEST", {
|
|
846
|
+
message: "Missing data.id in webhook payload",
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const isValid = verifyWebhookSignature({
|
|
850
|
+
xSignature,
|
|
851
|
+
xRequestId,
|
|
852
|
+
dataId,
|
|
853
|
+
secret: options.webhookSecret,
|
|
854
|
+
});
|
|
855
|
+
if (!isValid) {
|
|
856
|
+
ctx.context.logger.error("Invalid webhook signature", {
|
|
857
|
+
xSignature,
|
|
858
|
+
xRequestId,
|
|
859
|
+
dataId,
|
|
860
|
+
});
|
|
861
|
+
throw new APIError("UNAUTHORIZED", {
|
|
862
|
+
message: "Invalid webhook signature",
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// Idempotency: prevent duplicate webhook processing
|
|
867
|
+
const webhookId = `webhook:${notification.data?.id}:${notification.type}`;
|
|
868
|
+
const alreadyProcessed = idempotencyStore.get(webhookId);
|
|
869
|
+
if (alreadyProcessed) {
|
|
870
|
+
ctx.context.logger.info("Webhook already processed", { webhookId });
|
|
871
|
+
return ctx.json({ received: true });
|
|
872
|
+
}
|
|
873
|
+
// Mark as being processed
|
|
874
|
+
idempotencyStore.set(webhookId, true, 24 * 60 * 60 * 1000); // 24 hours
|
|
875
|
+
try {
|
|
876
|
+
// Handle payment notifications
|
|
877
|
+
if (notification.type === "payment") {
|
|
878
|
+
const paymentId = notification.data.id;
|
|
879
|
+
if (!paymentId) {
|
|
880
|
+
throw new APIError("BAD_REQUEST", {
|
|
881
|
+
message: "Missing payment ID",
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
// Fetch payment details from MP
|
|
885
|
+
let mpPayment;
|
|
886
|
+
try {
|
|
887
|
+
mpPayment = (await paymentClient.get({
|
|
888
|
+
id: paymentId,
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
ctx.context.logger.error("Failed to fetch payment from MP", {
|
|
893
|
+
paymentId,
|
|
894
|
+
error,
|
|
895
|
+
});
|
|
896
|
+
throw new APIError("BAD_REQUEST", {
|
|
897
|
+
message: "Failed to fetch payment details",
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
// Update payment in database
|
|
901
|
+
const existingPayment = await ctx.context.adapter.findOne({
|
|
902
|
+
model: "mercadoPagoPayment",
|
|
903
|
+
where: [
|
|
904
|
+
{
|
|
905
|
+
field: "mercadoPagoPaymentId",
|
|
906
|
+
value: paymentId.toString(),
|
|
907
|
+
},
|
|
908
|
+
],
|
|
909
|
+
});
|
|
910
|
+
if (existingPayment) {
|
|
911
|
+
// Validate amount hasn't been tampered with
|
|
912
|
+
if (!validatePaymentAmount(existingPayment.amount, mpPayment.transaction_amount || 0)) {
|
|
913
|
+
ctx.context.logger.error("Payment amount mismatch", {
|
|
914
|
+
expected: existingPayment.amount,
|
|
915
|
+
received: mpPayment.transaction_amount,
|
|
916
|
+
});
|
|
917
|
+
throw new APIError("BAD_REQUEST", {
|
|
918
|
+
message: "Payment amount mismatch",
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
await ctx.context.adapter.update({
|
|
922
|
+
model: "mercadoPagoPayment",
|
|
923
|
+
where: [{ field: "id", value: existingPayment.id }],
|
|
924
|
+
update: {
|
|
925
|
+
status: mpPayment.status,
|
|
926
|
+
statusDetail: mpPayment.status_detail || undefined,
|
|
927
|
+
paymentMethodId: mpPayment.payment_method_id || undefined,
|
|
928
|
+
paymentTypeId: mpPayment.payment_type_id || undefined,
|
|
929
|
+
updatedAt: new Date(),
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
// Execute callback if provided
|
|
933
|
+
if (options.onPaymentUpdate) {
|
|
934
|
+
try {
|
|
935
|
+
await options.onPaymentUpdate({
|
|
936
|
+
payment: existingPayment,
|
|
937
|
+
status: mpPayment.status,
|
|
938
|
+
statusDetail: mpPayment.status_detail || "",
|
|
939
|
+
mpPayment: mpPayment,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
ctx.context.logger.error("Error in onPaymentUpdate callback", { error });
|
|
944
|
+
// Don't throw - we still want to return 200
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Handle subscription (preapproval) notifications
|
|
950
|
+
if (notification.type === "subscription_preapproval" ||
|
|
951
|
+
notification.type === "subscription_preapproval_plan") {
|
|
952
|
+
const preapprovalId = notification.data.id;
|
|
953
|
+
if (!preapprovalId) {
|
|
954
|
+
throw new APIError("BAD_REQUEST", {
|
|
955
|
+
message: "Missing preapproval ID",
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
// Fetch preapproval details
|
|
959
|
+
let mpPreapproval;
|
|
960
|
+
try {
|
|
961
|
+
mpPreapproval = (await preApprovalClient.get({
|
|
962
|
+
id: preapprovalId,
|
|
963
|
+
}));
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
ctx.context.logger.error("Failed to fetch preapproval from MP", { preapprovalId, error });
|
|
967
|
+
throw new APIError("BAD_REQUEST", {
|
|
968
|
+
message: "Failed to fetch subscription details",
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
const existingSubscription = await ctx.context.adapter.findOne({
|
|
972
|
+
model: "mercadoPagoSubscription",
|
|
973
|
+
where: [
|
|
974
|
+
{
|
|
975
|
+
field: "mercadoPagoSubscriptionId",
|
|
976
|
+
value: preapprovalId,
|
|
977
|
+
},
|
|
978
|
+
],
|
|
979
|
+
});
|
|
980
|
+
if (existingSubscription) {
|
|
981
|
+
await ctx.context.adapter.update({
|
|
982
|
+
model: "mercadoPagoSubscription",
|
|
983
|
+
where: [{ field: "id", value: existingSubscription.id }],
|
|
984
|
+
update: {
|
|
985
|
+
status: mpPreapproval.status,
|
|
986
|
+
reason: mpPreapproval.reason || undefined,
|
|
987
|
+
nextPaymentDate: mpPreapproval.next_payment_date
|
|
988
|
+
? new Date(mpPreapproval.next_payment_date)
|
|
989
|
+
: undefined,
|
|
990
|
+
lastPaymentDate: mpPreapproval.last_modified
|
|
991
|
+
? new Date(mpPreapproval.last_modified)
|
|
992
|
+
: undefined,
|
|
993
|
+
summarized: mpPreapproval.summarized
|
|
994
|
+
? JSON.stringify(mpPreapproval.summarized)
|
|
995
|
+
: undefined,
|
|
996
|
+
updatedAt: new Date(),
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
// Execute callback if provided
|
|
1000
|
+
if (options.onSubscriptionUpdate) {
|
|
1001
|
+
try {
|
|
1002
|
+
await options.onSubscriptionUpdate({
|
|
1003
|
+
subscription: existingSubscription,
|
|
1004
|
+
status: mpPreapproval.status,
|
|
1005
|
+
reason: mpPreapproval.reason || "",
|
|
1006
|
+
mpPreapproval: mpPreapproval,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
catch (error) {
|
|
1010
|
+
ctx.context.logger.error("Error in onSubscriptionUpdate callback", { error });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// Handle authorized recurring payment
|
|
1016
|
+
if (notification.type ===
|
|
1017
|
+
"subscription_authorized_payment" ||
|
|
1018
|
+
notification.type === "authorized_payment") {
|
|
1019
|
+
const paymentId = notification.data.id;
|
|
1020
|
+
if (!paymentId) {
|
|
1021
|
+
throw new APIError("BAD_REQUEST", {
|
|
1022
|
+
message: "Missing payment ID",
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
// Handle recurring payment from subscription
|
|
1026
|
+
let mpPayment;
|
|
1027
|
+
try {
|
|
1028
|
+
// Cast the response to our typed interface
|
|
1029
|
+
mpPayment = (await paymentClient.get({
|
|
1030
|
+
id: paymentId,
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
ctx.context.logger.error("Failed to fetch authorized payment from MP", { paymentId, error });
|
|
1035
|
+
throw new APIError("BAD_REQUEST", {
|
|
1036
|
+
message: "Failed to fetch payment details",
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
// Link via external_reference (which contains the subscription ID)
|
|
1040
|
+
if (mpPayment.external_reference) {
|
|
1041
|
+
const subscription = await ctx.context.adapter.findOne({
|
|
1042
|
+
model: "mercadoPagoSubscription",
|
|
1043
|
+
where: [
|
|
1044
|
+
{
|
|
1045
|
+
field: "id",
|
|
1046
|
+
// External reference holds the local subscription ID
|
|
1047
|
+
value: mpPayment.external_reference,
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
});
|
|
1051
|
+
if (subscription) {
|
|
1052
|
+
// Update subscription last payment date
|
|
1053
|
+
// Note: In real scenarios, you might want to create a payment record here too
|
|
1054
|
+
// or just rely on the webhook to create it if it doesn't exist.
|
|
1055
|
+
// For now, we update the subscription and trigger the callback.
|
|
1056
|
+
if (options.onSubscriptionPayment) {
|
|
1057
|
+
try {
|
|
1058
|
+
await options.onSubscriptionPayment({
|
|
1059
|
+
subscription,
|
|
1060
|
+
// In a real app, we should map this properly or align types
|
|
1061
|
+
payment: mpPayment,
|
|
1062
|
+
status: mpPayment.status,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
ctx.context.logger.error("Error in onSubscriptionPayment callback", { error });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
ctx.context.logger.warn("Subscription not found for authorized payment", {
|
|
1072
|
+
paymentId,
|
|
1073
|
+
externalReference: mpPayment.external_reference,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
// Log error but return 200 to prevent MP from retrying
|
|
1081
|
+
ctx.context.logger.error("Error processing webhook", {
|
|
1082
|
+
error,
|
|
1083
|
+
notification,
|
|
1084
|
+
});
|
|
1085
|
+
// Only throw if it's a validation error that MP should know about
|
|
1086
|
+
if (error instanceof APIError) {
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return ctx.json({ received: true });
|
|
1091
|
+
}),
|
|
1092
|
+
},
|
|
1093
|
+
};
|
|
1094
|
+
};
|
|
1095
|
+
//# sourceMappingURL=server.js.map
|