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/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